]>
Commit | Line | Data |
---|---|---|
a4b75251 TL |
1 | import collections |
2 | import inspect | |
3 | from functools import wraps | |
4 | from typing import Optional | |
5 | ||
6 | import cherrypy | |
7 | ||
8 | from ..security import Permission | |
9 | from ._base_controller import BaseController | |
10 | from ._endpoint import Endpoint | |
11 | from ._helpers import _get_function_params | |
12 | from ._permissions import _set_func_permissions | |
13 | from ._version import APIVersion | |
14 | ||
15 | ||
16 | class RESTController(BaseController, skip_registry=True): | |
17 | """ | |
18 | Base class for providing a RESTful interface to a resource. | |
19 | ||
20 | To use this class, simply derive a class from it and implement the methods | |
21 | you want to support. The list of possible methods are: | |
22 | ||
23 | * list() | |
24 | * bulk_set(data) | |
25 | * create(data) | |
26 | * bulk_delete() | |
27 | * get(key) | |
28 | * set(data, key) | |
29 | * singleton_set(data) | |
30 | * delete(key) | |
31 | ||
32 | Test with curl: | |
33 | ||
34 | curl -H "Content-Type: application/json" -X POST \ | |
35 | -d '{"username":"xyz","password":"xyz"}' https://127.0.0.1:8443/foo | |
36 | curl https://127.0.0.1:8443/foo | |
37 | curl https://127.0.0.1:8443/foo/0 | |
38 | ||
39 | """ | |
40 | ||
41 | # resource id parameter for using in get, set, and delete methods | |
42 | # should be overridden by subclasses. | |
43 | # to specify a composite id (two parameters) use '/'. e.g., "param1/param2". | |
44 | # If subclasses don't override this property we try to infer the structure | |
45 | # of the resource ID. | |
46 | RESOURCE_ID: Optional[str] = None | |
47 | ||
48 | _permission_map = { | |
49 | 'GET': Permission.READ, | |
50 | 'POST': Permission.CREATE, | |
51 | 'PUT': Permission.UPDATE, | |
52 | 'DELETE': Permission.DELETE | |
53 | } | |
54 | ||
55 | _method_mapping = collections.OrderedDict([ | |
56 | ('list', {'method': 'GET', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}), # noqa E501 #pylint: disable=line-too-long | |
57 | ('create', {'method': 'POST', 'resource': False, 'status': 201, 'version': APIVersion.DEFAULT}), # noqa E501 #pylint: disable=line-too-long | |
58 | ('bulk_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}), # noqa E501 #pylint: disable=line-too-long | |
59 | ('bulk_delete', {'method': 'DELETE', 'resource': False, 'status': 204, 'version': APIVersion.DEFAULT}), # noqa E501 #pylint: disable=line-too-long | |
60 | ('get', {'method': 'GET', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}), | |
61 | ('delete', {'method': 'DELETE', 'resource': True, 'status': 204, 'version': APIVersion.DEFAULT}), # noqa E501 #pylint: disable=line-too-long | |
62 | ('set', {'method': 'PUT', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}), | |
63 | ('singleton_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}) # noqa E501 #pylint: disable=line-too-long | |
64 | ]) | |
65 | ||
66 | @classmethod | |
67 | def infer_resource_id(cls): | |
68 | if cls.RESOURCE_ID is not None: | |
69 | return cls.RESOURCE_ID.split('/') | |
70 | for k, v in cls._method_mapping.items(): | |
71 | func = getattr(cls, k, None) | |
72 | while hasattr(func, "__wrapped__"): | |
1e59de90 | 73 | assert func |
a4b75251 TL |
74 | func = func.__wrapped__ |
75 | if v['resource'] and func: | |
76 | path_params = cls.get_path_param_names() | |
77 | params = _get_function_params(func) | |
78 | return [p['name'] for p in params | |
79 | if p['required'] and p['name'] not in path_params] | |
80 | return None | |
81 | ||
82 | @classmethod | |
83 | def endpoints(cls): | |
84 | result = super().endpoints() | |
85 | res_id_params = cls.infer_resource_id() | |
86 | ||
1e59de90 | 87 | for name, func in inspect.getmembers(cls, predicate=callable): |
a4b75251 TL |
88 | endpoint_params = { |
89 | 'no_resource_id_params': False, | |
90 | 'status': 200, | |
91 | 'method': None, | |
92 | 'query_params': None, | |
93 | 'path': '', | |
94 | 'version': APIVersion.DEFAULT, | |
95 | 'sec_permissions': hasattr(func, '_security_permissions'), | |
96 | 'permission': None, | |
97 | } | |
1e59de90 | 98 | if name in cls._method_mapping: |
a4b75251 | 99 | cls._update_endpoint_params_method_map( |
1e59de90 | 100 | func, res_id_params, endpoint_params, name=name) |
a4b75251 TL |
101 | |
102 | elif hasattr(func, "__collection_method__"): | |
103 | cls._update_endpoint_params_collection_map(func, endpoint_params) | |
104 | ||
105 | elif hasattr(func, "__resource_method__"): | |
106 | cls._update_endpoint_params_resource_method( | |
107 | res_id_params, endpoint_params, func) | |
108 | ||
109 | else: | |
110 | continue | |
111 | ||
112 | if endpoint_params['no_resource_id_params']: | |
113 | raise TypeError("Could not infer the resource ID parameters for" | |
114 | " method {} of controller {}. " | |
115 | "Please specify the resource ID parameters " | |
116 | "using the RESOURCE_ID class property" | |
117 | .format(func.__name__, cls.__name__)) | |
118 | ||
119 | if endpoint_params['method'] in ['GET', 'DELETE']: | |
120 | params = _get_function_params(func) | |
121 | if res_id_params is None: | |
122 | res_id_params = [] | |
123 | if endpoint_params['query_params'] is None: | |
124 | endpoint_params['query_params'] = [p['name'] for p in params # type: ignore | |
125 | if p['name'] not in res_id_params] | |
126 | ||
127 | func = cls._status_code_wrapper(func, endpoint_params['status']) | |
128 | endp_func = Endpoint(endpoint_params['method'], path=endpoint_params['path'], | |
129 | query_params=endpoint_params['query_params'], | |
130 | version=endpoint_params['version'])(func) # type: ignore | |
131 | if endpoint_params['permission']: | |
132 | _set_func_permissions(endp_func, [endpoint_params['permission']]) | |
133 | result.append(cls.Endpoint(cls, endp_func)) | |
134 | ||
135 | return result | |
136 | ||
137 | @classmethod | |
138 | def _update_endpoint_params_resource_method(cls, res_id_params, endpoint_params, func): | |
139 | if not res_id_params: | |
140 | endpoint_params['no_resource_id_params'] = True | |
141 | else: | |
142 | path_params = ["{{{}}}".format(p) for p in res_id_params] | |
143 | endpoint_params['path'] += "/{}".format("/".join(path_params)) | |
144 | if func.__resource_method__['path']: | |
145 | endpoint_params['path'] += func.__resource_method__['path'] | |
146 | else: | |
147 | endpoint_params['path'] += "/{}".format(func.__name__) | |
148 | endpoint_params['status'] = func.__resource_method__['status'] | |
149 | endpoint_params['method'] = func.__resource_method__['method'] | |
150 | endpoint_params['version'] = func.__resource_method__['version'] | |
151 | endpoint_params['query_params'] = func.__resource_method__['query_params'] | |
152 | if not endpoint_params['sec_permissions']: | |
153 | endpoint_params['permission'] = cls._permission_map[endpoint_params['method']] | |
154 | ||
155 | @classmethod | |
156 | def _update_endpoint_params_collection_map(cls, func, endpoint_params): | |
157 | if func.__collection_method__['path']: | |
158 | endpoint_params['path'] = func.__collection_method__['path'] | |
159 | else: | |
160 | endpoint_params['path'] = "/{}".format(func.__name__) | |
161 | endpoint_params['status'] = func.__collection_method__['status'] | |
162 | endpoint_params['method'] = func.__collection_method__['method'] | |
163 | endpoint_params['query_params'] = func.__collection_method__['query_params'] | |
164 | endpoint_params['version'] = func.__collection_method__['version'] | |
165 | if not endpoint_params['sec_permissions']: | |
166 | endpoint_params['permission'] = cls._permission_map[endpoint_params['method']] | |
167 | ||
168 | @classmethod | |
1e59de90 TL |
169 | def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None): |
170 | meth = cls._method_mapping[func.__name__ if not name else name] # type: dict | |
a4b75251 TL |
171 | |
172 | if meth['resource']: | |
173 | if not res_id_params: | |
174 | endpoint_params['no_resource_id_params'] = True | |
175 | else: | |
176 | path_params = ["{{{}}}".format(p) for p in res_id_params] | |
177 | endpoint_params['path'] += "/{}".format("/".join(path_params)) | |
178 | ||
179 | endpoint_params['status'] = meth['status'] | |
180 | endpoint_params['method'] = meth['method'] | |
181 | if hasattr(func, "__method_map_method__"): | |
182 | endpoint_params['version'] = func.__method_map_method__['version'] | |
183 | if not endpoint_params['sec_permissions']: | |
184 | endpoint_params['permission'] = cls._permission_map[endpoint_params['method']] | |
185 | ||
186 | @classmethod | |
187 | def _status_code_wrapper(cls, func, status_code): | |
188 | @wraps(func) | |
189 | def wrapper(*vpath, **params): | |
190 | cherrypy.response.status = status_code | |
191 | return func(*vpath, **params) | |
192 | ||
193 | return wrapper | |
194 | ||
195 | @staticmethod | |
196 | def Resource(method=None, path=None, status=None, query_params=None, # noqa: N802 | |
197 | version: Optional[APIVersion] = APIVersion.DEFAULT): | |
198 | if not method: | |
199 | method = 'GET' | |
200 | ||
201 | if status is None: | |
202 | status = 200 | |
203 | ||
204 | def _wrapper(func): | |
205 | func.__resource_method__ = { | |
206 | 'method': method, | |
207 | 'path': path, | |
208 | 'status': status, | |
209 | 'query_params': query_params, | |
210 | 'version': version | |
211 | } | |
212 | return func | |
213 | return _wrapper | |
214 | ||
215 | @staticmethod | |
216 | def MethodMap(resource=False, status=None, | |
217 | version: Optional[APIVersion] = APIVersion.DEFAULT): # noqa: N802 | |
218 | ||
219 | if status is None: | |
220 | status = 200 | |
221 | ||
222 | def _wrapper(func): | |
223 | func.__method_map_method__ = { | |
224 | 'resource': resource, | |
225 | 'status': status, | |
226 | 'version': version | |
227 | } | |
228 | return func | |
229 | return _wrapper | |
230 | ||
231 | @staticmethod | |
232 | def Collection(method=None, path=None, status=None, query_params=None, # noqa: N802 | |
233 | version: Optional[APIVersion] = APIVersion.DEFAULT): | |
234 | if not method: | |
235 | method = 'GET' | |
236 | ||
237 | if status is None: | |
238 | status = 200 | |
239 | ||
240 | def _wrapper(func): | |
241 | func.__collection_method__ = { | |
242 | 'method': method, | |
243 | 'path': path, | |
244 | 'status': status, | |
245 | 'query_params': query_params, | |
246 | 'version': version | |
247 | } | |
248 | return func | |
249 | return _wrapper |