comparison roundup/rest.py @ 6638:e1588ae185dc issue2550923_computed_property

merge from default branch. Fix travis.ci so CI builds don't error out
author John Rouillard <rouilj@ieee.org>
date Thu, 21 Apr 2022 16:54:17 -0400
parents 01a5dd90286e
children 9a1f5e496e6c
comparison
equal deleted inserted replaced
6508:85db90cc1705 6638:e1588ae185dc
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.
173 216
174 node_etag = calculate_etag(node, key, classname, id, 217 node_etag = calculate_etag(node, key, classname, id,
175 repr_format=repr_format) 218 repr_format=repr_format)
176 219
177 for etag in etags: 220 for etag in etags:
178 if etag is not None: 221 # etag includes doublequotes around tag:
179 if etag != node_etag: 222 # '"a46a5572190e4fad63958c135f3746fa"'
223 # but can include content-encoding suffix like:
224 # '"a46a5572190e4fad63958c135f3746fa-gzip"'
225 # turn the latter into the former as we don't care what
226 # encoding was used to send the body with the etag.
227 try:
228 suffix_start = etag.rindex('-')
229 clean_etag = etag[:suffix_start] + '"'
230 except (ValueError, AttributeError):
231 # - not in etag or etag is None
232 clean_etag = etag
233 if clean_etag is not None:
234 if clean_etag != node_etag:
180 return False 235 return False
181 have_etag_match = True 236 have_etag_match = True
182 237
183 if have_etag_match: 238 if have_etag_match:
184 return True 239 return True
345 match_obj = path_regex.match(path) 400 match_obj = path_regex.match(path)
346 if match_obj: 401 if match_obj:
347 try: 402 try:
348 func_obj = funcs[method] 403 func_obj = funcs[method]
349 except KeyError: 404 except KeyError:
350 raise Reject('Method %s not allowed' % method) 405 valid_methods = ', '.join(sorted(funcs.keys()))
406 raise Reject(_('Method %(m)s not allowed. '
407 'Allowed: %(a)s')% {
408 'm': method,
409 'a': valid_methods
410 },
411 valid_methods)
351 412
352 # retrieve the vars list and the function caller 413 # retrieve the vars list and the function caller
353 list_vars = func_obj['vars'] 414 list_vars = func_obj['vars']
354 func = func_obj['func'] 415 func = func_obj['func']
355 416
500 if '.' in p: 561 if '.' in p:
501 prop = None 562 prop = None
502 for pn in p.split('.'): 563 for pn in p.split('.'):
503 # Tried to dereference a non-Link property 564 # Tried to dereference a non-Link property
504 if cn is None: 565 if cn is None:
505 raise AttributeError("Unknown: %s" % p) 566 raise UsageError("Property %(base)s can not be dereferenced in %(p)s." % { "base": p[:-(len(pn)+1)], "p": p})
506 cls = self.db.getclass(cn) 567 cls = self.db.getclass(cn)
507 # This raises a KeyError for unknown prop: 568 # This raises a KeyError for unknown prop:
508 try: 569 try:
509 prop = cls.getprops(protected=True)[pn] 570 prop = cls.getprops(protected=True)[pn]
510 except KeyError: 571 except KeyError:
511 raise AttributeError("Unknown: %s" % p) 572 raise KeyError("Unknown property: %s" % p)
512 if isinstance(prop, hyperdb.Multilink): 573 if isinstance(prop, hyperdb.Multilink):
513 raise UsageError( 574 raise UsageError(
514 'Multilink Traversal not allowed: %s' % p) 575 'Multilink Traversal not allowed: %s' % p)
515 # Now we have the classname in cn and the prop name in pn. 576 # Now we have the classname in cn and the prop name in pn.
516 if not self.db.security.hasPermission('View', uid, cn, pn): 577 if not self.db.security.hasPermission('View', uid, cn, pn):
519 % (cn, pn))) 580 % (cn, pn)))
520 try: 581 try:
521 cn = prop.classname 582 cn = prop.classname
522 except AttributeError: 583 except AttributeError:
523 cn = None 584 cn = None
585 else:
586 cls = self.db.getclass(cn)
587 # This raises a KeyError for unknown prop:
588 try:
589 prop = cls.getprops(protected=True)[pn]
590 except KeyError:
591 raise KeyError("Unknown property: %s" % pn)
524 checked_props.append (p) 592 checked_props.append (p)
525 return checked_props 593 return checked_props
526 594
527 def error_obj(self, status, msg, source=None): 595 def error_obj(self, status, msg, source=None):
528 """Return an error object""" 596 """Return an error object"""
739 verbose = int(value) 807 verbose = int(value)
740 elif key == "@fields" or key == "@attrs": 808 elif key == "@fields" or key == "@attrs":
741 f = value.split(",") 809 f = value.split(",")
742 if len(f) == 1: 810 if len(f) == 1:
743 f = value.split(":") 811 f = value.split(":")
744 allprops = class_obj.getprops(protected=True)
745 display_props.update(self.transitive_props(class_name, f)) 812 display_props.update(self.transitive_props(class_name, f))
746 elif key == "@sort": 813 elif key == "@sort":
747 f = value.split(",") 814 f = value.split(",")
748 allprops = class_obj.getprops(protected=True)
749 for p in f: 815 for p in f:
750 if not p: 816 if not p:
751 raise UsageError("Empty property " 817 raise UsageError("Empty property "
752 "for class %s." % (class_name)) 818 "for class %s." % (class_name))
753 if p[0] in ('-', '+'): 819 if p[0] in ('-', '+'):
782 try: 848 try:
783 prop = class_obj.getprops()[p] 849 prop = class_obj.getprops()[p]
784 except KeyError: 850 except KeyError:
785 raise UsageError("Field %s is not valid for %s class." % 851 raise UsageError("Field %s is not valid for %s class." %
786 (p, class_name)) 852 (p, class_name))
853 # Call this for the side effect of validating the key
854 # use _discard as _ is apparently a global for the translation
855 # service.
856 _discard = self.transitive_props(class_name, [ key ])
787 # We drop properties without search permission silently 857 # We drop properties without search permission silently
788 # This reflects the current behavior of other roundup 858 # This reflects the current behavior of other roundup
789 # interfaces 859 # interfaces
790 # Note that hasSearchPermission already returns 0 for 860 # Note that hasSearchPermission already returns 0 for
791 # non-existing properties. 861 # non-existing properties.
889 for field in input.value 959 for field in input.value
890 if field.name != "@page_index"])}) 960 if field.name != "@page_index"])})
891 961
892 result['@total_size'] = result_len 962 result['@total_size'] = result_len
893 self.client.setHeader("X-Count-Total", str(result_len)) 963 self.client.setHeader("X-Count-Total", str(result_len))
964 self.client.setHeader("Allow", "OPTIONS, GET, POST")
894 return 200, result 965 return 200, result
895 966
896 @Routing.route("/data/<:class_name>/<:item_id>", 'GET') 967 @Routing.route("/data/<:class_name>/<:item_id>", 'GET')
897 @_data_decorator 968 @_data_decorator
898 def get_element(self, class_name, item_id, input): 969 def get_element(self, class_name, item_id, input):
960 props = set() 1031 props = set()
961 # support , or : separated elements 1032 # support , or : separated elements
962 f = value.split(",") 1033 f = value.split(",")
963 if len(f) == 1: 1034 if len(f) == 1:
964 f = value.split(":") 1035 f = value.split(":")
965 allprops = class_obj.getprops(protected=True)
966 props.update(self.transitive_props(class_name, f)) 1036 props.update(self.transitive_props(class_name, f))
967 elif key == "@protected": 1037 elif key == "@protected":
968 # allow client to request read only 1038 # allow client to request read only
969 # properties like creator, activity etc. 1039 # properties like creator, activity etc.
970 # used only if no @fields/@attrs 1040 # used only if no @fields/@attrs
1026 1096
1027 class_obj = self.db.getclass(class_name) 1097 class_obj = self.db.getclass(class_name)
1028 node = class_obj.getnode(item_id) 1098 node = class_obj.getnode(item_id)
1029 etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY, 1099 etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY,
1030 class_name, item_id, repr_format="json") 1100 class_name, item_id, repr_format="json")
1031 data = node.__getattr__(attr_name) 1101 try:
1102 data = node.__getattr__(attr_name)
1103 except AttributeError as e:
1104 raise UsageError(_("Invalid attribute %s"%attr_name))
1032 result = { 1105 result = {
1033 'id': item_id, 1106 'id': item_id,
1034 'type': str(type(data)), 1107 'type': str(type(data)),
1035 'link': "%s/%s/%s/%s" % 1108 'link': "%s/%s/%s/%s" %
1036 (self.data_path, class_name, item_id, attr_name), 1109 (self.data_path, class_name, item_id, attr_name),
1186 raise UsageError("Must provide the %s property." % msg) 1259 raise UsageError("Must provide the %s property." % msg)
1187 1260
1188 # set the header Location 1261 # set the header Location
1189 link = '%s/%s/%s' % (self.data_path, class_name, item_id) 1262 link = '%s/%s/%s' % (self.data_path, class_name, item_id)
1190 self.client.setHeader("Location", link) 1263 self.client.setHeader("Location", link)
1264
1265 self.client.setHeader(
1266 "Allow",
1267 None
1268 )
1269 self.client.setHeader(
1270 "Access-Control-Allow-Methods",
1271 None
1272 )
1191 1273
1192 # set the response body 1274 # set the response body
1193 result = { 1275 result = {
1194 'id': item_id, 1276 'id': item_id,
1195 'link': link 1277 'link': link
1639 """ 1721 """
1640 if class_name not in self.db.classes: 1722 if class_name not in self.db.classes:
1641 raise NotFound('Class %s not found' % class_name) 1723 raise NotFound('Class %s not found' % class_name)
1642 self.client.setHeader( 1724 self.client.setHeader(
1643 "Allow", 1725 "Allow",
1726 "OPTIONS, GET, POST"
1727 )
1728
1729 self.client.setHeader(
1730 "Access-Control-Allow-Methods",
1644 "OPTIONS, GET, POST" 1731 "OPTIONS, GET, POST"
1645 ) 1732 )
1646 return 204, "" 1733 return 204, ""
1647 1734
1648 @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS') 1735 @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS')
1696 else: 1783 else:
1697 raise NotFound('Attribute %s not valid for Class %s' % ( 1784 raise NotFound('Attribute %s not valid for Class %s' % (
1698 attr_name, class_name)) 1785 attr_name, class_name))
1699 return 204, "" 1786 return 204, ""
1700 1787
1788 @openapi_doc({"summary": "Describe Roundup rest endpoint.",
1789 "description": ("Report all supported api versions "
1790 "and default api version. "
1791 "Also report next level of link "
1792 "endpoints below /rest endpoint"),
1793 "responses": {
1794 "200": {
1795 "description": "Successful response.",
1796 "content": {
1797 "application/json": {
1798 "examples": {
1799 "success": {
1800 "summary": "Normal json data.",
1801 "value": """{
1802 "data": {
1803 "default_version": 1,
1804 "supported_versions": [
1805 1
1806 ],
1807 "links": [
1808 {
1809 "uri": "https://tracker.example.com/demo/rest",
1810 "rel": "self"
1811 },
1812 {
1813 "uri": "https://tracker.example.com/demo/rest/data",
1814 "rel": "data"
1815 },
1816 {
1817 "uri": "https://tracker.example.com/demo/rest/summary",
1818 "rel": "summary"
1819 }
1820 ]
1821 }
1822 }"""
1823 }
1824 }
1825 },
1826 "application/xml": {
1827 "examples": {
1828 "success": {
1829 "summary": "Normal xml data",
1830 "value": """<dataf type="dict">
1831 <default_version type="int">1</default_version>
1832 <supported_versions type="list">
1833 <item type="int">1</item>
1834 </supported_versions>
1835 <links type="list">
1836 <item type="dict">
1837 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest</uri>
1838 <rel type="str">self</rel>
1839 </item>
1840 <item type="dict">
1841 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/data</uri>
1842 <rel type="str">data</rel>
1843 </item>
1844 <item type="dict">
1845 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/summary</uri>
1846 <rel type="str">summary</rel>
1847 </item>
1848 <item type="dict">
1849 <uri type="str">https://rouilj.dynamic-dns.net/sysadmin/rest/summary2</uri>
1850 <rel type="str">summary2</rel>
1851 </item>
1852 </links>
1853 </dataf>"""
1854 }
1855 }
1856 }
1857 }
1858 }
1859 }
1860 }
1861 )
1701 @Routing.route("/") 1862 @Routing.route("/")
1702 @_data_decorator 1863 @_data_decorator
1703 def describe(self, input): 1864 def describe(self, input):
1704 """Describe the rest endpoint""" 1865 """Describe the rest endpoint. Return direct children in
1866 links list.
1867 """
1868
1869 # paths looks like ['^rest/$', '^rest/summary$',
1870 # '^rest/data/<:class>$', ...]
1871 paths = Routing._Routing__route_map.keys()
1872
1873 links = []
1874 # p[1:-1] removes ^ and $ from regexp
1875 # if p has only 1 /, it's a child of rest/ root.
1876 child_paths = sorted([ p[1:-1] for p in paths if
1877 p.count('/') == 1 ])
1878 for p in child_paths:
1879 # p.split('/')[1] is the residual path after
1880 # removing rest/. child_paths look like:
1881 # ['rest/', 'rest/summary'] etc.
1882 rel = p.split('/')[1]
1883 if rel:
1884 rel_path = "/" + rel
1885 else:
1886 rel_path = rel
1887 rel = "self"
1888 links.append( {"uri": self.base_path + rel_path,
1889 "rel": rel
1890 })
1891
1705 result = { 1892 result = {
1706 "default_version": self.__default_api_version, 1893 "default_version": self.__default_api_version,
1707 "supported_versions": self.__supported_api_versions, 1894 "supported_versions": self.__supported_api_versions,
1708 "links": [{"uri": self.base_path + "/summary", 1895 "links": links
1709 "rel": "summary"},
1710 {"uri": self.base_path,
1711 "rel": "self"},
1712 {"uri": self.base_path + "/data",
1713 "rel": "data"}]
1714 } 1896 }
1715 1897
1716 return 200, result 1898 return 200, result
1717 1899
1718 @Routing.route("/", 'OPTIONS') 1900 @Routing.route("/", 'OPTIONS')
1947 self.api_version = int(part[1]['version']) 2129 self.api_version = int(part[1]['version'])
1948 except KeyError: 2130 except KeyError:
1949 self.api_version = None 2131 self.api_version = None
1950 except (ValueError, TypeError): 2132 except (ValueError, TypeError):
1951 # TypeError if int(None) 2133 # TypeError if int(None)
1952 msg = ("Unrecognized version: %s. " 2134 msg = ("Unrecognized api version: %s. "
1953 "See /rest without specifying version " 2135 "See /rest without specifying api version "
1954 "for supported versions." % ( 2136 "for supported versions." % (
1955 part[1]['version'])) 2137 part[1]['version']))
1956 output = self.error_obj(400, msg) 2138 output = self.error_obj(400, msg)
1957 2139
1958 # get the request format for response 2140 # get the request format for response
1959 # priority : extension from uri (/rest/data/issue.json), 2141 # priority : extension from uri (/rest/data/issue.json),
1960 # header (Accept: application/json, application/xml) 2142 # header (Accept: application/json, application/xml)
1961 # default (application/json) 2143 # default (application/json)
1962 ext_type = os.path.splitext(urlparse(uri).path)[1][1:] 2144 ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
1963 2145
2146 # Check to see if the length of the extension is less than 6.
2147 # this allows use of .vcard for a future use in downloading
2148 # user info. It also allows passing through larger items like
2149 # JWT that has a final component > 6 items. This method also
2150 # allow detection of mistyped types like jon for json.
2151 if ext_type and (len(ext_type) < 6):
2152 # strip extension so uri make sense
2153 # .../issue.json -> .../issue
2154 uri = uri[:-(len(ext_type) + 1)]
2155 else:
2156 ext_type = None
2157
1964 # headers.get('Accept') is never empty if called here. 2158 # headers.get('Accept') is never empty if called here.
1965 # accept_type will be set to json if there is no Accept header 2159 # accept_type will be set to json if there is no Accept header
1966 # accept_type wil be empty only if there is an Accept header 2160 # accept_type wil be empty only if there is an Accept header
1967 # with invalid values. 2161 # with invalid values.
1968 data_type = ext_type or accept_type or headers.get('Accept') or "invalid" 2162 data_type = ext_type or accept_type or headers.get('Accept') or "invalid"
1969 2163
1970 if (ext_type):
1971 # strip extension so uri make sense
1972 # .../issue.json -> .../issue
1973 uri = uri[:-(len(ext_type) + 1)]
1974
1975 # add access-control-allow-* to support CORS 2164 # add access-control-allow-* to support CORS
1976 self.client.setHeader("Access-Control-Allow-Origin", "*") 2165 self.client.setHeader("Access-Control-Allow-Origin", "*")
1977 self.client.setHeader( 2166 self.client.setHeader(
1978 "Access-Control-Allow-Headers", 2167 "Access-Control-Allow-Headers",
1979 "Content-Type, Authorization, X-HTTP-Method-Override" 2168 "Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override"
1980 ) 2169 )
1981 self.client.setHeader( 2170 self.client.setHeader(
1982 "Allow", 2171 "Allow",
1983 "OPTIONS, GET, POST, PUT, DELETE, PATCH" 2172 "OPTIONS, GET, POST, PUT, DELETE, PATCH"
1984 ) 2173 )
1985 self.client.setHeader( 2174 self.client.setHeader(
1986 "Access-Control-Allow-Methods", 2175 "Access-Control-Allow-Methods",
1987 "HEAD, OPTIONS, GET, PUT, DELETE, PATCH" 2176 "HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH"
1988 ) 2177 )
1989 # Is there an input.value with format json data? 2178 # Is there an input.value with format json data?
1990 # If so turn it into an object that emulates enough 2179 # If so turn it into an object that emulates enough
1991 # of the FieldStorge methods/props to allow a response. 2180 # of the FieldStorge methods/props to allow a response.
1992 content_type_header = headers.get('Content-Type', None) 2181 content_type_header = headers.get('Content-Type', None)
2021 except (KeyError, TypeError): 2210 except (KeyError, TypeError):
2022 pretty_output = True 2211 pretty_output = True
2023 2212
2024 # check for runtime statistics 2213 # check for runtime statistics
2025 try: 2214 try:
2215 # self.report_stats initialized to False
2026 self.report_stats = input['@stats'].value.lower() == "true" 2216 self.report_stats = input['@stats'].value.lower() == "true"
2027 # Can also return a TypeError ("not indexable") 2217 # Can also return a TypeError ("not indexable")
2028 # In case the FieldStorage could not parse the result 2218 # In case the FieldStorage could not parse the result
2029 except (KeyError, TypeError): 2219 except (KeyError, TypeError):
2030 report_stats = False 2220 pass
2031 2221
2032 # check for @apiver in query string 2222 # check for @apiver in query string
2033 msg = ("Unrecognized version: %s. " 2223 msg = _("Unrecognized api version: %s. "
2034 "See /rest without specifying version " 2224 "See /rest without specifying api version "
2035 "for supported versions.") 2225 "for supported versions.")
2036 try: 2226 try:
2037 if not self.api_version: 2227 if not self.api_version:
2038 self.api_version = int(input['@apiver'].value) 2228 self.api_version = int(input['@apiver'].value)
2039 # Can also return a TypeError ("not indexable") 2229 # Can also return a TypeError ("not indexable")
2050 # version? This may be a good thing to require. Note that: 2240 # version? This may be a good thing to require. Note that:
2051 # Accept: application/json; version=1 may not be legal but.... 2241 # Accept: application/json; version=1 may not be legal but....
2052 # Use default if not specified for now. 2242 # Use default if not specified for now.
2053 self.api_version = self.__default_api_version 2243 self.api_version = self.__default_api_version
2054 elif self.api_version not in self.__supported_api_versions: 2244 elif self.api_version not in self.__supported_api_versions:
2055 raise UsageError(msg % self.api_version) 2245 output = self.error_obj(400, msg % self.api_version)
2056 2246
2057 # sadly del doesn't work on FieldStorage which can be the type of 2247 # sadly del doesn't work on FieldStorage which can be the type of
2058 # input. So we have to ignore keys starting with @ at other 2248 # input. So we have to ignore keys starting with @ at other
2059 # places in the code. 2249 # places in the code.
2060 # else: 2250 # else:
2067 if not output: 2257 if not output:
2068 output = Routing.execute(self, uri, method, input) 2258 output = Routing.execute(self, uri, method, input)
2069 except NotFound as msg: 2259 except NotFound as msg:
2070 output = self.error_obj(404, msg) 2260 output = self.error_obj(404, msg)
2071 except Reject as msg: 2261 except Reject as msg:
2072 output = self.error_obj(405, msg) 2262 output = self.error_obj(405, msg.args[0])
2263 self.client.setHeader("Allow", msg.args[1])
2073 2264
2074 # Format the content type 2265 # Format the content type
2075 if data_type.lower() == "json": 2266 if data_type.lower() == "json":
2076 self.client.setHeader("Content-Type", "application/json") 2267 self.client.setHeader("Content-Type", "application/json")
2077 if pretty_output: 2268 if pretty_output:

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