1import abc
2import logging
3import typing as t
4from collections import defaultdict
5
6from starlette.types import ASGIApp, Receive, Scope, Send
7
8from connexion.exceptions import MissingMiddleware, ResolverError
9from connexion.http_facts import METHODS
10from connexion.operations import AbstractOperation
11from connexion.resolver import Resolver
12from connexion.spec import Specification
13
14logger = logging.getLogger(__name__)
15
16ROUTING_CONTEXT = "connexion_routing"
17
18
19class SpecMiddleware(abc.ABC):
20 """Middlewares that need the specification(s) to be registered on them should inherit from this
21 base class"""
22
23 @abc.abstractmethod
24 def add_api(self, specification: Specification, **kwargs) -> t.Any:
25 """
26 Register an API represented by a single OpenAPI specification on this middleware.
27 Multiple APIs can be registered on a single middleware.
28 """
29
30 @abc.abstractmethod
31 async def __call__(self, scope: Scope, receive: Receive, send: Send):
32 pass
33
34
35class AbstractSpecAPI:
36 """Base API class with only minimal behavior related to the specification."""
37
38 def __init__(
39 self,
40 specification: Specification,
41 base_path: t.Optional[str] = None,
42 resolver: t.Optional[Resolver] = None,
43 uri_parser_class=None,
44 *args,
45 **kwargs,
46 ):
47 self.specification = specification
48 self.uri_parser_class = uri_parser_class
49
50 self._set_base_path(base_path)
51
52 self.resolver = resolver or Resolver()
53
54 def _set_base_path(self, base_path: t.Optional[str] = None) -> None:
55 if base_path is not None:
56 # update spec to include user-provided base_path
57 self.specification.base_path = base_path
58 self.base_path = base_path
59 else:
60 self.base_path = self.specification.base_path
61
62
63OP = t.TypeVar("OP")
64"""Typevar representing an operation"""
65
66
67class AbstractRoutingAPI(AbstractSpecAPI, t.Generic[OP]):
68 """Base API class with shared functionality related to routing."""
69
70 def __init__(
71 self,
72 *args,
73 pythonic_params=False,
74 resolver_error_handler: t.Optional[t.Callable] = None,
75 **kwargs,
76 ) -> None:
77 super().__init__(*args, **kwargs)
78 self.pythonic_params = pythonic_params
79 self.resolver_error_handler = resolver_error_handler
80
81 self.add_paths()
82
83 def add_paths(self, paths: t.Optional[dict] = None) -> None:
84 """
85 Adds the paths defined in the specification as operations.
86 """
87 paths = t.cast(dict, paths or self.specification.get("paths", dict()))
88 for path, methods in paths.items():
89 logger.debug("Adding %s%s...", self.base_path, path)
90
91 for method in methods:
92 if method not in METHODS:
93 continue
94 try:
95 self.add_operation(path, method)
96 except ResolverError as err:
97 # If we have an error handler for resolver errors, add it as an operation.
98 # Otherwise treat it as any other error.
99 if self.resolver_error_handler is not None:
100 self._add_resolver_error_handler(method, path, err)
101 else:
102 self._handle_add_operation_error(path, method, err)
103 except Exception as e:
104 # All other relevant exceptions should be handled as well.
105 self._handle_add_operation_error(path, method, e)
106
107 def add_operation(self, path: str, method: str) -> None:
108 """
109 Adds one operation to the api.
110
111 This method uses the OperationID identify the module and function that will handle the operation
112
113 From Swagger Specification:
114
115 **OperationID**
116
117 A friendly name for the operation. The id MUST be unique among all operations described in the API.
118 Tools and libraries MAY use the operation id to uniquely identify an operation.
119 """
120 spec_operation_cls = self.specification.operation_cls
121 spec_operation = spec_operation_cls.from_spec(
122 self.specification,
123 path=path,
124 method=method,
125 resolver=self.resolver,
126 uri_parser_class=self.uri_parser_class,
127 )
128 operation = self.make_operation(spec_operation)
129 path, name = self._framework_path_and_name(spec_operation, path)
130 self._add_operation_internal(method, path, operation, name=name)
131
132 @abc.abstractmethod
133 def make_operation(self, operation: AbstractOperation) -> OP:
134 """Build an operation to register on the API."""
135
136 @staticmethod
137 @abc.abstractmethod
138 def _framework_path_and_name(
139 operation: AbstractOperation, path: str
140 ) -> t.Tuple[str, str]:
141 """Prepare the framework path & name to register the operation on the API."""
142
143 @abc.abstractmethod
144 def _add_operation_internal(
145 self, method: str, path: str, operation: OP, name: t.Optional[str] = None
146 ) -> None:
147 """
148 Adds the operation according to the user framework in use.
149 It will be used to register the operation on the user framework router.
150 """
151
152 def _add_resolver_error_handler(
153 self, method: str, path: str, err: ResolverError
154 ) -> None:
155 """
156 Adds a handler for ResolverError for the given method and path.
157 """
158 self.resolver_error_handler = t.cast(t.Callable, self.resolver_error_handler)
159 operation = self.resolver_error_handler(
160 err,
161 )
162 self._add_operation_internal(method, path, operation)
163
164 def _handle_add_operation_error(
165 self, path: str, method: str, exc: Exception
166 ) -> None:
167 url = f"{self.base_path}{path}"
168 error_msg = f"Failed to add operation for {method.upper()} {url}"
169 logger.error(error_msg)
170 raise exc from None
171
172
173class RoutedAPI(AbstractSpecAPI, t.Generic[OP]):
174 def __init__(
175 self,
176 specification: Specification,
177 *args,
178 next_app: ASGIApp,
179 **kwargs,
180 ) -> None:
181 super().__init__(specification, *args, **kwargs)
182 self.next_app = next_app
183 self.operations: t.MutableMapping[t.Optional[str], OP] = {}
184
185 def add_paths(self) -> None:
186 paths = self.specification.get("paths", {})
187 for path, methods in paths.items():
188 for method in methods:
189 if method not in METHODS:
190 continue
191 try:
192 self.add_operation(path, method)
193 except ResolverError:
194 # ResolverErrors are either raised or handled in routing middleware.
195 pass
196
197 def add_operation(self, path: str, method: str) -> None:
198 operation_spec_cls = self.specification.operation_cls
199 operation = operation_spec_cls.from_spec(
200 self.specification,
201 path=path,
202 method=method,
203 resolver=self.resolver,
204 uri_parser_class=self.uri_parser_class,
205 )
206 routed_operation = self.make_operation(operation)
207 self.operations[operation.operation_id] = routed_operation
208
209 @abc.abstractmethod
210 def make_operation(self, operation: AbstractOperation) -> OP:
211 """Create an operation of the `operation_cls` type."""
212 raise NotImplementedError
213
214
215API = t.TypeVar("API", bound="RoutedAPI")
216"""Typevar representing an API which subclasses RoutedAPI"""
217
218
219class RoutedMiddleware(SpecMiddleware, t.Generic[API]):
220 """Baseclass for middleware that wants to leverage the RoutingMiddleware to route requests to
221 its operations.
222
223 The RoutingMiddleware adds the operation_id to the ASGI scope. This middleware registers its
224 operations by operation_id at startup. At request time, the operation is fetched by an
225 operation_id lookup.
226 """
227
228 api_cls: t.Type[API]
229 """The subclass of RoutedAPI this middleware uses."""
230
231 def __init__(self, app: ASGIApp) -> None:
232 self.app = app
233 self.apis: t.Dict[str, t.List[API]] = defaultdict(list)
234
235 def add_api(self, specification: Specification, **kwargs) -> API:
236 api = self.api_cls(specification, next_app=self.app, **kwargs)
237 self.apis[api.base_path].append(api)
238 return api
239
240 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
241 """Fetches the operation related to the request and calls it."""
242 if scope["type"] != "http":
243 await self.app(scope, receive, send)
244 return
245
246 try:
247 connexion_context = scope["extensions"][ROUTING_CONTEXT]
248 except KeyError:
249 raise MissingMiddleware(
250 "Could not find routing information in scope. Please make sure "
251 "you have a routing middleware registered upstream. "
252 )
253 api_base_path = connexion_context.get("api_base_path")
254 if api_base_path is not None and api_base_path in self.apis:
255 for api in self.apis[api_base_path]:
256 operation_id = connexion_context.get("operation_id")
257 try:
258 operation = api.operations[operation_id]
259 except KeyError:
260 if operation_id is None:
261 logger.debug("Skipping operation without id.")
262 await self.app(scope, receive, send)
263 return
264 else:
265 return await operation(scope, receive, send)
266
267 raise MissingOperation("Encountered unknown operation_id.")
268
269 await self.app(scope, receive, send)
270
271
272class MissingOperation(Exception):
273 """Missing operation"""