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:

Roundup Issue Tracker: http://roundup-tracker.org/