Source code for flask_jsonrpc.descriptor

# Copyright (c) 2024-2025, Cenobit Technologies, Inc. http://cenobit.es/
# 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 the Cenobit Technologies 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.
from __future__ import annotations

import typing as t
from collections import OrderedDict
from urllib.parse import urlsplit

# Added in version 3.11.
from typing_extensions import Self

from flask_jsonrpc import typing as fjt
from flask_jsonrpc.conf import settings
from flask_jsonrpc.types import params as types_params, methods as types_methods
from flask_jsonrpc.helpers import from_python_type
from flask_jsonrpc.types.types import Object, propertify

if t.TYPE_CHECKING:
    from flask_jsonrpc.site import JSONRPCSite

JSONRPC_DESCRIBE_METHOD_NAME: str = 'rpc.describe'
JSONRPC_DESCRIBE_SERVICE_METHOD_TYPE: str = 'method'


[docs] class JSONRPCServiceDescriptor: """JSON-RPC Service Descriptor for JSON-RPC 2.0. It provides a detailed description of the JSON-RPC service, including its methods, parameters, return types, and other metadata. Args: jsonrpc_site (flask_jsonrpc.site.JSONRPCSite): JSON-RPC site instance. Attributes: jsonrpc_site (flask_jsonrpc.site.JSONRPCSite): JSON-RPC site instance. """ def __init__(self: Self, jsonrpc_site: JSONRPCSite) -> None: self.jsonrpc_site = jsonrpc_site self.register(jsonrpc_site) def _python_type_name(self: Self, pytype: t.Any) -> str: # noqa: ANN401 """Get the JSON-RPC type name for a given Python type. Args: pytype (typing.Any): Python type. Returns: str: JSON-RPC type name. """ return str(from_python_type(pytype)) def _build_field_desc(self: Self, field: fjt.Field, annotations: t.Annotated[t.Any, ...]) -> fjt.Field: # noqa: ANN401, C901 """Build a field description from annotations. Args: field (flask_jsonrpc.typing.Field): Field instance. annotations (typing.Annotated[typing.Any, ...]): Annotations for the field. Returns: flask_jsonrpc.typing.Field: Field instance with updated description. """ for annotation in annotations: if isinstance(annotation, types_params.Summary): field.summary = annotation.summary continue if isinstance(annotation, types_params.Description): field.description = annotation.description continue if isinstance(annotation, types_params.Properties): field.properties = ( self._properties_to_fields(annotation.annotations) if isinstance(annotation.annotations, dict) else None ) continue if isinstance(annotation, types_params.Example): if field.examples is None: # pragma: no cover field.examples = [] field.examples.append(annotation.value) continue if isinstance(annotation, types_params.Required): field.required = annotation.required continue if isinstance(annotation, types_params.Deprecated): field.deprecated = annotation.deprecated continue if isinstance(annotation, types_params.Nullable): field.nullable = annotation.nullable continue if isinstance(annotation, types_params.Maximum): field.maximum = annotation.maximum continue if isinstance(annotation, types_params.Minimum): field.minimum = annotation.minimum continue if isinstance(annotation, types_params.MultipleOf): field.multiple_of = annotation.multiple_of continue if isinstance(annotation, types_params.MaxLength): field.max_length = annotation.max_length continue if isinstance(annotation, types_params.MinLength): field.min_length = annotation.min_length continue if isinstance(annotation, types_params.Pattern): field.pattern = annotation.pattern continue if isinstance(annotation, types_params.AllowInfNan): field.allow_inf_nan = annotation.allow_inf_nan continue if isinstance(annotation, types_params.MaxDigits): field.max_digits = annotation.max_digits continue if isinstance(annotation, types_params.DecimalPlaces): field.decimal_places = annotation.decimal_places continue return field def _properties_to_fields( self: Self, annotations: dict[str, t.Annotated[t.Any, ...] | t.Any] ) -> dict[str, fjt.Field] | None: """Convert properties annotations to field descriptions. Args: annotations (dict[str, typing.Annotated[typing.Any, ...] | typing.Any]): Annotations for the properties. Returns: dict[str, flask_jsonrpc.typing.Field] | None: Field descriptions for the properties. """ fields = {} for name, annotation in annotations.items(): if isinstance(annotation, types_params.Properties): field_type = Object.name properties = ( self._properties_to_fields(annotation.annotations) if isinstance(annotation.annotations, dict) else None ) fields[name] = fjt.Field(name=name, type=field_type, properties=properties if properties else None) continue field = fjt.Field(name=name, type=self._python_type_name(getattr(annotation, '__origin__', type(None)))) fields[name] = self._build_field_desc(field, getattr(annotation, '__metadata__', ())) return fields def _build_service_field_desc(self: Self, name: str, obj: t.Any) -> fjt.Field: # noqa: ANN401, C901 """Build a service field description from a Python type. Args: name (str): Field name. obj (typing.Any): Python type. Returns: flask_jsonrpc.typing.Field: Field description. """ annotations = getattr(obj, '__metadata__', ()) obj_type = getattr(obj, '__origin__', obj) if t.get_origin(obj) is t.Annotated else obj field_type = self._python_type_name(obj_type) field = fjt.Field(name=name, type=field_type) if ( len([x for x in annotations if isinstance(x, types_params.Properties)]) == 0 and from_python_type(obj_type, default=None) is None ): properties = propertify(obj_type) annotations = annotations + (properties,) return self._build_field_desc(field, annotations) def _service_method_params_desc(self: Self, view_func: t.Callable[..., t.Any]) -> list[fjt.Field]: """Get the service method parameters description. Args: view_func (typing.Callable[..., typing.Any]): The view function. Returns: list[flask_jsonrpc.typing.Field]: List of field descriptions. """ view_func_params = getattr(view_func, 'jsonrpc_method_params', {}) fields = [] for param_name, param_type in view_func_params.items(): fields.append(self._build_service_field_desc(param_name, param_type)) return fields def _service_method_returns_desc(self: Self, view_func: t.Callable[..., t.Any]) -> fjt.Field: """Get the service method return description. Args: view_func (typing.Callable[..., typing.Any]): The view function. Returns: flask_jsonrpc.typing.Field: Field description. """ view_func_return_type = getattr(view_func, 'jsonrpc_method_return', type(None)) return self._build_service_field_desc('default', view_func_return_type) def _service_methods_desc(self: Self) -> t.OrderedDict[str, fjt.Method]: # noqa: C901 """Get the service methods description. Returns: OrderedDict[str, flask_jsonrpc.typing.Method]: Ordered dictionary of method descriptions. """ methods: t.OrderedDict[str, fjt.Method] = OrderedDict() for name, view_func in self.jsonrpc_site.view_funcs.items(): method_name = getattr(view_func, 'jsonrpc_method_name', name) method_annotation: t.Any | types_methods.MethodAnnotatedType = getattr( view_func, 'jsonrpc_method_annotations', types_methods.MethodAnnotated[None], # type: ignore ) method_metadata = getattr(method_annotation, '__metadata__', ()) method_options = getattr(view_func, 'jsonrpc_options', {}) method = fjt.Method( name=method_name, type=JSONRPC_DESCRIBE_SERVICE_METHOD_TYPE, validation=method_options.get('validate', settings.DEFAULT_JSONRPC_METHOD_VALIDATE), notification=method_options.get('notification', settings.DEFAULT_JSONRPC_METHOD_NOTIFICATION), params=self._service_method_params_desc(view_func), returns=self._service_method_returns_desc(view_func), ) # mypyc: pydantic optional value method.description = getattr(view_func, '__doc__', None) for metadata in method_metadata: if isinstance(metadata, types_methods.Summary): method.summary = metadata.summary continue if isinstance(metadata, types_methods.Description): method.description = metadata.description continue if isinstance(metadata, types_methods.Validate): method.validation = metadata.validate continue if isinstance(metadata, types_methods.Notification): method.notification = metadata.notification continue if isinstance(metadata, types_methods.Deprecated): method.deprecated = metadata.deprecated continue if isinstance(metadata, types_methods.Tag): if method.tags is None: # pragma: no cover method.tags = [] method.tags.append(metadata.name) continue if isinstance(metadata, types_methods.Error): if method.errors is None: # pragma: no cover method.errors = [] method.errors.append( fjt.Error( code=metadata.code, message=metadata.message, status_code=metadata.status_code, data=metadata.data, ) ) continue if isinstance(metadata, types_methods.Example): if method.examples is None: # pragma: no cover method.examples = [] method.examples.append( fjt.Example( name=metadata.name, summary=metadata.summary, description=metadata.description, params=[ fjt.ExampleField( name=param.name, value=param.value, summary=param.summary, description=param.description, ) for param in (metadata.params or []) ], returns=fjt.ExampleField( name=metadata.returns.name, value=metadata.returns.value, summary=metadata.returns.summary, description=metadata.returns.description, ) if metadata.returns is not None else None, ) ) continue methods[method_name] = method return methods def _service_server_url(self: Self) -> str: """Get the service server URL. Returns: str: Service server URL. """ url = urlsplit(self.jsonrpc_site.base_url or self.jsonrpc_site.path or '') return ( f'{url.scheme}://{url.netloc}/{(self.jsonrpc_site.path or "").lstrip("/")}' if self.jsonrpc_site.base_url else url.path )
[docs] def service_describe(self: Self) -> fjt.ServiceDescribe: """Get the service description. Returns: flask_jsonrpc.typing.ServiceDescribe: Service description. """ from flask_jsonrpc.site import JSONRPCSite serv_desc = fjt.ServiceDescribe( id=f'urn:uuid:{self.jsonrpc_site.uuid}', version=self.jsonrpc_site.version, name=self.jsonrpc_site.name, servers=[fjt.Server(url=self._service_server_url())], methods=self._service_methods_desc(), ) # mypyc: pydantic optional value serv_desc.description = ( self.jsonrpc_site.__doc__ if self.jsonrpc_site.__doc__ != getattr(JSONRPCSite, '__doc__', None) else None ) return serv_desc
[docs] def register(self: Self, jsonrpc_site: JSONRPCSite) -> None: """Register the service description method. The 'rpc.describe' is automatically registered to provide service description. Args: jsonrpc_site (flask_jsonrpc.site.JSONRPCSite): JSON-RPC site instance. Examples: >>> from flask import Flask >>> from flask_jsonrpc import JSONRPC >>> >>> app = Flask(__name__) >>> jsonrpc = JSONRPC(app, path='/api', version='1.0.0') >>> assert 'rpc.describe' in jsonrpc.get_jsonrpc_site().view_funcs """ def describe() -> fjt.ServiceDescribe: return self.service_describe() describe.__doc__ = 'Service description for JSON-RPC 2.0' typing_annotations: types_methods.MethodAnnotatedType = types_methods.MethodAnnotated[ types_methods.Summary('RPC Describe'), types_methods.Description('Service description for JSON-RPC 2.0'), types_methods.Validate(False), types_methods.Notification(False), ] fn_annotations = {'return': t.Annotated[fjt.ServiceDescribe, None]} setattr(describe, 'jsonrpc_method_name', JSONRPC_DESCRIBE_METHOD_NAME) # noqa: B010 setattr(describe, 'jsonrpc_method_sig', fn_annotations.copy()) # noqa: B010 setattr(describe, 'jsonrpc_method_return', fn_annotations.pop('return')) # noqa: B010 setattr(describe, 'jsonrpc_method_params', fn_annotations) # noqa: B010 setattr(describe, 'jsonrpc_method_annotations', typing_annotations) # noqa: B010 setattr(describe, 'jsonrpc_validate', False) # noqa: B010 setattr(describe, 'jsonrpc_notification', False) # noqa: B010 setattr(describe, 'jsonrpc_options', {'notification': False, 'validate': False}) # noqa: B010 jsonrpc_site.register(JSONRPC_DESCRIBE_METHOD_NAME, describe) self.describe = describe