Source code for flask_more_smorest.crud.blueprint_operationid

"""Enhanced Blueprint with automatic operationId generation.

This module provides BlueprintOperationIdMixin that extends Flask-Smorest's
Blueprint to automatically generate OpenAPI operationId values for endpoints.
"""

from collections.abc import Callable
from typing import Any, Final, cast

import inflect
from flask.views import MethodView
from flask_smorest import Blueprint

from ..utils import convert_snake_to_camel

inflector = inflect.engine()


[docs] def strip_suffixes(name: str) -> str: """Strip common boilerplate suffixes from class or function names. Strips suffixes in priority order so compound suffixes like ``V2List`` are handled correctly (``V2`` removed first, then ``List``). Args: name: Class or function name to normalise. Returns: Name with recognised suffixes removed. Example: >>> strip_suffixes("UserListView") 'User' >>> strip_suffixes("AccreditationIndexV2") 'Accreditation' >>> strip_suffixes("FeeEstimationView") 'FeeEstimation' """ # Order matters — strip version tags before structural tags suffixes = [ "V2", "_v2", "V1", "_v1", "MethodView", "View", "Index", "List", "Collection", ] for suffix in suffixes: if name.endswith(suffix) and len(name) > len(suffix): name = name[: -len(suffix)] return name
HTTP_METHOD_OPERATION_MAP: Final[dict[str, str]] = { "patch": "update", "delete": "delete", "get": "get", "post": "create", "put": "set", }
[docs] class BlueprintOperationIdMixin(Blueprint): """Blueprint mixin that provides automatic operationId generation. Extends Flask-Smorest's Blueprint to automatically generate OpenAPI ``operationId`` values for routes based on the route pattern, HTTP method, class name (after suffix stripping) and response schema. **Collection detection** for GET requests (in priority order): 1. Manual ``@bp.doc(operationId=…)`` override → used as-is. 2. Explicit ``operation_id=`` kwarg on ``route()`` → used as-is. 3. Trailing slash in path → collection (``listXxx``). 4. ``many=True`` on response schema → collection (``listXxx``). 5. Default → individual (``getXxx``). Examples:: bp = BlueprintOperationIdMixin('users', __name__) # Auto-generated: listUsers @bp.route('/users/') class User(MethodView): def get(self): ... # Custom full operationId for a function-based route @bp.route('/special', operation_id='getSpecialUsers') def special_users(): ... # Prefix for all methods on a MethodView (e.g. deprecation) @bp.route('/legacy', operation_id_prefix='_deprecated_') class Item(MethodView): def get(self): ... # → _deprecated_getItem def post(self): ... # → _deprecated_createItem # Suffix for versioning @bp.route('/v2/users/', operation_id_suffix='_v2') class User(MethodView): def get(self): ... # → listUsers_v2 # Combine prefix and suffix @bp.route('/old', operation_id_prefix='legacy_', operation_id_suffix='_v1') class OldEndpoint(MethodView): def get(self): ... # → legacy_getOldEndpoint_v1 """
[docs] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) # Keyed by (rule, methods_tuple) → full custom operationId self._route_operation_ids: dict[tuple[str, tuple[str, ...]], str] = {} # Keyed by (rule, methods_tuple) → prefix string self._route_operation_id_prefixes: dict[tuple[str, tuple[str, ...]], str] = {} # Keyed by (rule, methods_tuple) → suffix string self._route_operation_id_suffixes: dict[tuple[str, tuple[str, ...]], str] = {} # Keyed by (rule, id(view_func)) → (methods, prefix_or_custom, suffix, is_full_op_id) self._pending_route_info: dict[ tuple[str, int], tuple[tuple[str, ...], str | None, str | None, bool], ] = {} self._current_rule: str | None = None
[docs] def route( # pyright: ignore[reportIncompatibleMethodOverride] self, rule: str, *, operation_id: str | None = None, operation_id_prefix: str | None = None, operation_id_suffix: str | None = None, parameters: list | None = None, tags: list[str] | None = None, **options: Any, ) -> Callable[[type["MethodView"] | Callable], type["MethodView"] | Callable]: """Override route() to capture operationId customisation options. Args: rule: URL rule for the route. operation_id: Explicit full operationId (function-based routes) or override for every method on a MethodView. operation_id_prefix: Prefix prepended to the auto-generated operationId for every method on a MethodView. operation_id_suffix: Suffix appended to the auto-generated operationId for every method on a MethodView. parameters: OpenAPI path-level parameters (passed to parent). tags: OpenAPI tags (passed to parent). **options: Additional Flask routing options. Returns: Decorator for the view class or function. """ methods: tuple[str, ...] = tuple(sorted(options.get("methods", ("GET",)))) route_key = (rule, methods) if operation_id is not None: self._route_operation_ids[route_key] = operation_id if operation_id_prefix is not None: self._route_operation_id_prefixes[route_key] = operation_id_prefix if operation_id_suffix is not None: self._route_operation_id_suffixes[route_key] = operation_id_suffix return cast( Callable[[type["MethodView"] | Callable], type["MethodView"] | Callable], super().route(rule, parameters=parameters, tags=tags, **options), )
[docs] def add_url_rule( self, rule: str, endpoint: str | None = None, view_func: Callable | None = None, provide_automatic_options: bool | None = None, *, parameters: list | None = None, tags: list[str] | None = None, **options: Any, ) -> None: """Override to capture per-route operationId metadata before parent stores docs.""" methods: tuple[str, ...] = tuple(sorted(options.get("methods", ("GET",)))) route_key = (rule, methods) custom_op_id = self._route_operation_ids.get(route_key) prefix = self._route_operation_id_prefixes.get(route_key) suffix = self._route_operation_id_suffixes.get(route_key) if view_func is not None: pending_key = (rule, id(view_func)) self._pending_route_info[pending_key] = ( methods, custom_op_id if custom_op_id is not None else prefix, suffix, custom_op_id is not None, ) self._current_rule = rule super().add_url_rule( rule, endpoint, view_func, provide_automatic_options, parameters=parameters, tags=tags, **options, )
def _store_endpoint_docs( self, endpoint: str, obj: type["MethodView"] | Callable, parameters: Any, tags: Any, **options: Any, ) -> None: """Override to inject operationId into stored docs after all decorators are applied. Parent calls this to persist decorator metadata. We retrieve the previously stored customisation options and inject the operationId for each HTTP method that does not already carry a manual override. """ super()._store_endpoint_docs(endpoint, obj, parameters, tags, **options) rule = self._current_rule if rule is None: return pending_key = (rule, id(obj)) route_info = self._pending_route_info.pop(pending_key, None) if route_info is None: return _methods, prefix_or_custom, suffix, is_full_op_id = route_info endpoint_doc_info = self._docs.get(endpoint, {}) for method_lower, doc in endpoint_doc_info.items(): if method_lower == "parameters" or not isinstance(doc, dict): continue # Respect an existing manual operationId set by @bp.doc(operationId=...) if doc.get("manual_doc", {}).get("operationId"): continue if is_full_op_id: # Explicit full operationId supplied via route(operation_id=...) doc.setdefault("manual_doc", {})["operationId"] = prefix_or_custom continue # Detect collection for GET via many=True on response schema many = self._extract_many_from_response(doc) if method_lower == "get" else None op_id = self._generate_operation_id_for_method(obj, method_lower, rule, prefix_or_custom, suffix, many) doc.setdefault("manual_doc", {})["operationId"] = op_id # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _extract_many_from_response(self, doc: dict[str, Any]) -> bool: """Return True when the stored response schema carries ``many=True``. Inspects the ``response`` metadata stored by Flask-Smorest's ``@bp.response()`` decorator. Returns ``False`` if no schema is found or the attribute is absent. """ responses = doc.get("response", {}).get("responses", {}) for status_code in (200, 201, "200", "201"): entry = responses.get(status_code) if entry and isinstance(entry, (list, tuple)) and len(entry) > 0: first_entry = entry[0] if isinstance(first_entry, dict): schema = first_entry.get("schema") if schema is not None and hasattr(schema, "many"): return bool(schema.many) return False def _is_collection_endpoint( self, method_name: str, rule: str, many: bool | None = None, ) -> bool: """Return True when a GET endpoint represents a collection. Priority: 1. Trailing slash → collection. 2. ``many=True`` on response schema → collection. 3. Otherwise → individual. Args: method_name: Lowercase HTTP method name. rule: URL route pattern. many: Whether the response schema has ``many=True``. """ if method_name != "get": return False if rule.endswith("/"): return True return many is True def _pluralise(self, name: str) -> str: """Return a pluralised version of *name* using the ``inflect`` engine. Handles: * Already-plural words (e.g. ``News`` → ``News``). * Compound ``FooByBar`` form: only the prefix is pluralised (``AppointmentByRef`` → ``AppointmentsByRef``). Args: name: Singular (or already-plural) class name without suffixes. """ # Handle FooByBar compound form parts = name.split("By") if len(parts) > 1 and parts[1] and parts[1][0].isupper(): return f"{self._pluralise(parts[0])}By{parts[1]}" # If inflect considers it singular, pluralize it # pyright: ignore[reportArgumentType] if inflector.singular_noun(name) is False: # pyright: ignore[reportArgumentType] plural_form = inflector.plural_noun(name) # pyright: ignore[reportArgumentType] name = str(plural_form) if plural_form else name return name def _generate_operation_id_for_method( self, obj: type["MethodView"] | Callable, method_name: str, rule: str, prefix_or_custom: str | None, suffix: str | None = None, many: bool | None = None, ) -> str: """Build the final operationId string for one HTTP method. Args: obj: The MethodView subclass or function being decorated. method_name: Lowercase HTTP method (e.g. ``"get"``). rule: URL route pattern (used for trailing-slash detection). prefix_or_custom: Prefix string for MethodView; full ID for functions. suffix: Optional suffix to append (MethodView only). many: Whether the response schema has ``many=True`` (GET only). """ is_method_view = isinstance(obj, type) and issubclass(obj, MethodView) if not is_method_view and prefix_or_custom: # Function-based route with an explicit custom operationId return prefix_or_custom if is_method_view: class_name = strip_suffixes(obj.__name__) if self._is_collection_endpoint(method_name, rule, many): class_name = self._pluralise(class_name) operation_id = f"list{class_name}" else: operation_name = HTTP_METHOD_OPERATION_MAP.get(method_name, method_name) operation_id = f"{operation_name}{class_name}" else: # Function-based route: use function name after suffix stripping operation_id = strip_suffixes(obj.__name__) camel = convert_snake_to_camel(operation_id) # Ensure camelCase (lowercase first char) regardless of how # convert_snake_to_camel renders its output. base_id = camel[0].lower() + camel[1:] if camel else camel if is_method_view: if prefix_or_custom: base_id = prefix_or_custom + base_id if suffix: base_id = base_id + suffix return base_id