Mercurial > p > roundup > code
comparison roundup/rest.py @ 6525:c505c774a94d
Mutiple changes to REST code.
Requesting an invalid attribut via rest/data/class/id/attrib used to
return a 405, it now returns a 400 and a better error message.
/rest/ response scans the registered endpoints rather than using a
hard coded description. So new endpoints added in interfaces.py are
listed.
Fix a number of Allow headers that were listing invalid methods. Also
when invalid method is used, report valid methods in response. Extract
methods from Route list.
Fix Access-Control-Allow-Methods. Add X-Requested-With to
Access-Control-Allow-Headers.
Add decorator openapi_doc to add openapi annotations for the rest
endpoints. Added a couple of examples. Returning this info to a
client is still a work in progress.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Sun, 07 Nov 2021 01:04:43 -0500 |
| parents | a22ea1a7e92c |
| children | f8df7fed18f6 |
comparison
equal
deleted
inserted
replaced
| 6524:f961dbbc3573 | 6525:c505c774a94d |
|---|---|
| 121 data['@stats'] = self.db.stats | 121 data['@stats'] = self.db.stats |
| 122 result = { | 122 result = { |
| 123 'data': data | 123 'data': data |
| 124 } | 124 } |
| 125 return result | 125 return result |
| 126 | |
| 127 format_object.wrapped_func = func | |
| 126 return format_object | 128 return format_object |
| 127 | 129 |
| 130 def openapi_doc(d): | |
| 131 """Annotate rest routes with openapi data. Takes a dict | |
| 132 for the openapi spec. It can be used standalone | |
| 133 as the openapi spec paths.<path>.<method> = | |
| 134 | |
| 135 { | |
| 136 "summary": "this path gets a value", | |
| 137 "description": "a longer description", | |
| 138 "responses": { | |
| 139 "200": { | |
| 140 "description": "normal response", | |
| 141 "content": { | |
| 142 "application/json": {}, | |
| 143 "application/xml": {} | |
| 144 } | |
| 145 }, | |
| 146 "406": { | |
| 147 "description": "Unable to provide requested content type", | |
| 148 "content": { | |
| 149 "application/json": {} | |
| 150 } | |
| 151 } | |
| 152 }, | |
| 153 "parameters": [ | |
| 154 { | |
| 155 "$ref": "#components/parameters/generic_.stats" | |
| 156 }, | |
| 157 { | |
| 158 "$ref": "#components/parameters/generic_.apiver" | |
| 159 }, | |
| 160 { | |
| 161 "$ref": "#components/parameters/generic_.verbose" | |
| 162 } | |
| 163 ] | |
| 164 } | |
| 165 """ | |
| 166 | |
| 167 def wrapper(f): | |
| 168 f.openapi_doc = d | |
| 169 return f | |
| 170 return wrapper | |
| 128 | 171 |
| 129 def calculate_etag(node, key, classname="Missing", id="0", | 172 def calculate_etag(node, key, classname="Missing", id="0", |
| 130 repr_format="json"): | 173 repr_format="json"): |
| 131 '''given a hyperdb node generate a hashed representation of it to be | 174 '''given a hyperdb node generate a hashed representation of it to be |
| 132 used as an etag. | 175 used as an etag. |
| 345 match_obj = path_regex.match(path) | 388 match_obj = path_regex.match(path) |
| 346 if match_obj: | 389 if match_obj: |
| 347 try: | 390 try: |
| 348 func_obj = funcs[method] | 391 func_obj = funcs[method] |
| 349 except KeyError: | 392 except KeyError: |
| 350 raise Reject('Method %s not allowed' % method) | 393 valid_methods = ', '.join(sorted(funcs.keys())) |
| 394 raise Reject(_('Method %(m)s not allowed. ' | |
| 395 'Allowed: %(a)s')% { | |
| 396 'm': method, | |
| 397 'a': valid_methods | |
| 398 }, | |
| 399 valid_methods) | |
| 351 | 400 |
| 352 # retrieve the vars list and the function caller | 401 # retrieve the vars list and the function caller |
| 353 list_vars = func_obj['vars'] | 402 list_vars = func_obj['vars'] |
| 354 func = func_obj['func'] | 403 func = func_obj['func'] |
| 355 | 404 |
| 889 for field in input.value | 938 for field in input.value |
| 890 if field.name != "@page_index"])}) | 939 if field.name != "@page_index"])}) |
| 891 | 940 |
| 892 result['@total_size'] = result_len | 941 result['@total_size'] = result_len |
| 893 self.client.setHeader("X-Count-Total", str(result_len)) | 942 self.client.setHeader("X-Count-Total", str(result_len)) |
| 943 self.client.setHeader("Allow", "OPTIONS, GET, POST") | |
| 894 return 200, result | 944 return 200, result |
| 895 | 945 |
| 896 @Routing.route("/data/<:class_name>/<:item_id>", 'GET') | 946 @Routing.route("/data/<:class_name>/<:item_id>", 'GET') |
| 897 @_data_decorator | 947 @_data_decorator |
| 898 def get_element(self, class_name, item_id, input): | 948 def get_element(self, class_name, item_id, input): |
| 1026 | 1076 |
| 1027 class_obj = self.db.getclass(class_name) | 1077 class_obj = self.db.getclass(class_name) |
| 1028 node = class_obj.getnode(item_id) | 1078 node = class_obj.getnode(item_id) |
| 1029 etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY, | 1079 etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY, |
| 1030 class_name, item_id, repr_format="json") | 1080 class_name, item_id, repr_format="json") |
| 1031 data = node.__getattr__(attr_name) | 1081 try: |
| 1082 data = node.__getattr__(attr_name) | |
| 1083 except AttributeError as e: | |
| 1084 raise UsageError(_("Invalid attribute %s"%attr_name)) | |
| 1032 result = { | 1085 result = { |
| 1033 'id': item_id, | 1086 'id': item_id, |
| 1034 'type': str(type(data)), | 1087 'type': str(type(data)), |
| 1035 'link': "%s/%s/%s/%s" % | 1088 'link': "%s/%s/%s/%s" % |
| 1036 (self.data_path, class_name, item_id, attr_name), | 1089 (self.data_path, class_name, item_id, attr_name), |
| 1186 raise UsageError("Must provide the %s property." % msg) | 1239 raise UsageError("Must provide the %s property." % msg) |
| 1187 | 1240 |
| 1188 # set the header Location | 1241 # set the header Location |
| 1189 link = '%s/%s/%s' % (self.data_path, class_name, item_id) | 1242 link = '%s/%s/%s' % (self.data_path, class_name, item_id) |
| 1190 self.client.setHeader("Location", link) | 1243 self.client.setHeader("Location", link) |
| 1244 | |
| 1245 self.client.setHeader( | |
| 1246 "Allow", | |
| 1247 None | |
| 1248 ) | |
| 1249 self.client.setHeader( | |
| 1250 "Access-Control-Allow-Methods", | |
| 1251 None | |
| 1252 ) | |
| 1191 | 1253 |
| 1192 # set the response body | 1254 # set the response body |
| 1193 result = { | 1255 result = { |
| 1194 'id': item_id, | 1256 'id': item_id, |
| 1195 'link': link | 1257 'link': link |
| 1639 """ | 1701 """ |
| 1640 if class_name not in self.db.classes: | 1702 if class_name not in self.db.classes: |
| 1641 raise NotFound('Class %s not found' % class_name) | 1703 raise NotFound('Class %s not found' % class_name) |
| 1642 self.client.setHeader( | 1704 self.client.setHeader( |
| 1643 "Allow", | 1705 "Allow", |
| 1706 "OPTIONS, GET, POST" | |
| 1707 ) | |
| 1708 | |
| 1709 self.client.setHeader( | |
| 1710 "Access-Control-Allow-Methods", | |
| 1644 "OPTIONS, GET, POST" | 1711 "OPTIONS, GET, POST" |
| 1645 ) | 1712 ) |
| 1646 return 204, "" | 1713 return 204, "" |
| 1647 | 1714 |
| 1648 @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS') | 1715 @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS') |
| 1696 else: | 1763 else: |
| 1697 raise NotFound('Attribute %s not valid for Class %s' % ( | 1764 raise NotFound('Attribute %s not valid for Class %s' % ( |
| 1698 attr_name, class_name)) | 1765 attr_name, class_name)) |
| 1699 return 204, "" | 1766 return 204, "" |
| 1700 | 1767 |
| 1768 @openapi_doc({"summary": "Describe Roundup rest endpoint.", | |
| 1769 "description": ("Report all supported api versions " | |
| 1770 "and default api version. " | |
| 1771 "Also report next level of link " | |
| 1772 "endpoints below /rest endpoint"), | |
| 1773 "responses": { | |
| 1774 "200": { | |
| 1775 "description": "Successful response.", | |
| 1776 "content": { | |
| 1777 "application/json": { | |
| 1778 "examples": { | |
| 1779 "success": { | |
| 1780 "summary": "Normal json data.", | |
| 1781 "value": """{ | |
| 1782 "data": { | |
| 1783 "default_version": 1, | |
| 1784 "supported_versions": [ | |
| 1785 1 | |
| 1786 ], | |
| 1787 "links": [ | |
| 1788 { | |
| 1789 "uri": "https://tracker.example.com/demo/rest", | |
| 1790 "rel": "self" | |
| 1791 }, | |
| 1792 { | |
| 1793 "uri": "https://tracker.example.com/demo/rest/data", | |
| 1794 "rel": "data" | |
| 1795 }, | |
| 1796 { | |
| 1797 "uri": "https://tracker.example.com/demo/rest/summary", | |
| 1798 "rel": "summary" | |
| 1799 } | |
| 1800 ] | |
| 1801 } | |
| 1802 }""" | |
| 1803 } | |
| 1804 } | |
| 1805 }, | |
| 1806 "application/xml": { | |
| 1807 "examples": { | |
| 1808 "success": { | |
| 1809 "summary": "Normal xml data", | |
| 1810 "value": """<dataf type="dict"> | |
| 1811 <default_version type="int">1</default_version> | |
| 1812 <supported_versions type="list"> | |
| 1813 <item type="int">1</item> | |
| 1814 </supported_versions> | |
| 1815 <links type="list"> | |
| 1816 <item type="dict"> | |
| 1817 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest</uri> | |
| 1818 <rel type="str">self</rel> | |
| 1819 </item> | |
| 1820 <item type="dict"> | |
| 1821 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/data</uri> | |
| 1822 <rel type="str">data</rel> | |
| 1823 </item> | |
| 1824 <item type="dict"> | |
| 1825 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/summary</uri> | |
| 1826 <rel type="str">summary</rel> | |
| 1827 </item> | |
| 1828 <item type="dict"> | |
| 1829 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/summary2</uri> | |
| 1830 <rel type="str">summary2</rel> | |
| 1831 </item> | |
| 1832 </links> | |
| 1833 </dataf>""" | |
| 1834 } | |
| 1835 } | |
| 1836 } | |
| 1837 } | |
| 1838 } | |
| 1839 } | |
| 1840 } | |
| 1841 ) | |
| 1701 @Routing.route("/") | 1842 @Routing.route("/") |
| 1702 @_data_decorator | 1843 @_data_decorator |
| 1703 def describe(self, input): | 1844 def describe(self, input): |
| 1704 """Describe the rest endpoint""" | 1845 """Describe the rest endpoint. Return direct children in |
| 1846 links list. | |
| 1847 """ | |
| 1848 | |
| 1849 # paths looks like ['^rest/$', '^rest/summary$', | |
| 1850 # '^rest/data/<:class>$', ...] | |
| 1851 paths = Routing._Routing__route_map.keys() | |
| 1852 | |
| 1853 links = [] | |
| 1854 # p[1:-1] removes ^ and $ from regexp | |
| 1855 # if p has only 1 /, it's a child of rest/ root. | |
| 1856 child_paths = sorted([ p[1:-1] for p in paths if | |
| 1857 p.count('/') == 1 ]) | |
| 1858 for p in child_paths: | |
| 1859 # p.split('/')[1] is the residual path after | |
| 1860 # removing rest/. child_paths look like: | |
| 1861 # ['rest/', 'rest/summary'] etc. | |
| 1862 rel = p.split('/')[1] | |
| 1863 if rel: | |
| 1864 rel_path = "/" + rel | |
| 1865 else: | |
| 1866 rel_path = rel | |
| 1867 rel = "self" | |
| 1868 links.append( {"uri": self.base_path + rel_path, | |
| 1869 "rel": rel | |
| 1870 }) | |
| 1871 | |
| 1705 result = { | 1872 result = { |
| 1706 "default_version": self.__default_api_version, | 1873 "default_version": self.__default_api_version, |
| 1707 "supported_versions": self.__supported_api_versions, | 1874 "supported_versions": self.__supported_api_versions, |
| 1708 "links": [{"uri": self.base_path + "/summary", | 1875 "links": links |
| 1709 "rel": "summary"}, | |
| 1710 {"uri": self.base_path, | |
| 1711 "rel": "self"}, | |
| 1712 {"uri": self.base_path + "/data", | |
| 1713 "rel": "data"}] | |
| 1714 } | 1876 } |
| 1715 | 1877 |
| 1716 return 200, result | 1878 return 200, result |
| 1717 | 1879 |
| 1718 @Routing.route("/", 'OPTIONS') | 1880 @Routing.route("/", 'OPTIONS') |
| 1981 | 2143 |
| 1982 # add access-control-allow-* to support CORS | 2144 # add access-control-allow-* to support CORS |
| 1983 self.client.setHeader("Access-Control-Allow-Origin", "*") | 2145 self.client.setHeader("Access-Control-Allow-Origin", "*") |
| 1984 self.client.setHeader( | 2146 self.client.setHeader( |
| 1985 "Access-Control-Allow-Headers", | 2147 "Access-Control-Allow-Headers", |
| 1986 "Content-Type, Authorization, X-HTTP-Method-Override" | 2148 "Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override" |
| 1987 ) | 2149 ) |
| 1988 self.client.setHeader( | 2150 self.client.setHeader( |
| 1989 "Allow", | 2151 "Allow", |
| 1990 "OPTIONS, GET, POST, PUT, DELETE, PATCH" | 2152 "OPTIONS, GET, POST, PUT, DELETE, PATCH" |
| 1991 ) | 2153 ) |
| 1992 self.client.setHeader( | 2154 self.client.setHeader( |
| 1993 "Access-Control-Allow-Methods", | 2155 "Access-Control-Allow-Methods", |
| 1994 "HEAD, OPTIONS, GET, PUT, DELETE, PATCH" | 2156 "HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH" |
| 1995 ) | 2157 ) |
| 1996 # Is there an input.value with format json data? | 2158 # Is there an input.value with format json data? |
| 1997 # If so turn it into an object that emulates enough | 2159 # If so turn it into an object that emulates enough |
| 1998 # of the FieldStorge methods/props to allow a response. | 2160 # of the FieldStorge methods/props to allow a response. |
| 1999 content_type_header = headers.get('Content-Type', None) | 2161 content_type_header = headers.get('Content-Type', None) |
| 2074 if not output: | 2236 if not output: |
| 2075 output = Routing.execute(self, uri, method, input) | 2237 output = Routing.execute(self, uri, method, input) |
| 2076 except NotFound as msg: | 2238 except NotFound as msg: |
| 2077 output = self.error_obj(404, msg) | 2239 output = self.error_obj(404, msg) |
| 2078 except Reject as msg: | 2240 except Reject as msg: |
| 2079 output = self.error_obj(405, msg) | 2241 output = self.error_obj(405, msg.args[0]) |
| 2242 self.client.setHeader("Allow", msg.args[1]) | |
| 2080 | 2243 |
| 2081 # Format the content type | 2244 # Format the content type |
| 2082 if data_type.lower() == "json": | 2245 if data_type.lower() == "json": |
| 2083 self.client.setHeader("Content-Type", "application/json") | 2246 self.client.setHeader("Content-Type", "application/json") |
| 2084 if pretty_output: | 2247 if pretty_output: |
