Coverage for nova/api/validation/__init__.py: 94%

98 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-17 15:08 +0000

1# Copyright 2013 NEC Corporation. All rights reserved. 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); you may 

4# not use this file except in compliance with the License. You may obtain 

5# a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 

11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 

12# License for the specific language governing permissions and limitations 

13# under the License. 

14""" 

15Request Body validating middleware. 

16 

17""" 

18 

19import functools 

20import re 

21import typing as ty 

22 

23from oslo_log import log as logging 

24from oslo_serialization import jsonutils 

25import webob 

26 

27from nova.api.openstack import api_version_request as api_version 

28from nova.api.openstack import wsgi 

29from nova.api.validation import validators 

30import nova.conf 

31from nova import exception 

32from nova.i18n import _ 

33 

34CONF = nova.conf.CONF 

35LOG = logging.getLogger(__name__) 

36 

37 

38def _schema_validation_helper(schema, target, min_version, max_version, 

39 args, kwargs, is_body=True): 

40 """A helper method to execute JSON-Schema Validation. 

41 

42 This method checks the request version whether matches the specified max 

43 version and min_version. It also takes a care of legacy v2 request. 

44 

45 If the version range matches the request, we validate the schema against 

46 the target and a failure will result in a ValidationError being raised. 

47 

48 :param schema: A dict, the JSON-Schema is used to validate the target. 

49 :param target: A dict, the target is validated by the JSON-Schema. 

50 :param min_version: A string of two numerals. X.Y indicating the minimum 

51 version of the JSON-Schema to validate against. 

52 :param max_version: A string of two numerals. X.Y indicating the maximum 

53 version of the JSON-Schema to validate against. 

54 :param args: Positional arguments which passed into original method. 

55 :param kwargs: Keyword arguments which passed into original method. 

56 :param is_body: A boolean. Indicating whether the target is HTTP request 

57 body or not. 

58 :returns: A boolean. `True` if and only if the version range matches the 

59 request AND the schema is successfully validated. `False` if the 

60 version range does not match the request and no validation is 

61 performed. 

62 :raises: ValidationError, when the validation fails. 

63 """ 

64 min_ver = api_version.APIVersionRequest(min_version) 

65 max_ver = api_version.APIVersionRequest(max_version) 

66 

67 # The request object is always the second argument. 

68 # However numerous unittests pass in the request object 

69 # via kwargs instead so we handle that as well. 

70 # TODO(cyeoh): cleanup unittests so we don't have to 

71 # to do this 

72 if 'req' in kwargs: 

73 ver = kwargs['req'].api_version_request 

74 legacy_v2 = kwargs['req'].is_legacy_v2() 

75 else: 

76 ver = args[1].api_version_request 

77 legacy_v2 = args[1].is_legacy_v2() 

78 

79 if legacy_v2: 

80 # NOTE: For v2.0 compatible API, here should work like 

81 # client | schema min_version | schema 

82 # -----------+--------------------+-------- 

83 # legacy_v2 | None | work 

84 # legacy_v2 | 2.0 | work 

85 # legacy_v2 | 2.1+ | don't 

86 if min_version is None or min_version == '2.0': 

87 schema_validator = validators._SchemaValidator( 

88 schema, legacy_v2, is_body) 

89 schema_validator.validate(target) 

90 return True 

91 elif ver.matches(min_ver, max_ver): 

92 # Only validate against the schema if it lies within 

93 # the version range specified. Note that if both min 

94 # and max are not specified the validator will always 

95 # be run. 

96 schema_validator = validators._SchemaValidator( 

97 schema, legacy_v2, is_body) 

98 schema_validator.validate(target) 

99 return True 

100 

101 return False 

102 

103 

104# TODO(stephenfin): This decorator should take the five schemas we validate: 

105# request body, request query string, request headers, response body, and 

106# response headers. As things stand, we're going to need five separate 

107# decorators. 

108def schema( 

109 request_body_schema: ty.Dict[str, ty.Any], 

110 min_version: ty.Optional[str] = None, 

111 max_version: ty.Optional[str] = None, 

112) -> ty.Dict[str, ty.Any]: 

113 """Register a schema to validate request body. 

114 

115 Registered schema will be used for validating request body just before 

116 API method executing. 

117 

118 :param dict request_body_schema: a schema to validate request body 

119 :param dict response_body_schema: a schema to validate response body 

120 :param str min_version: Minimum API microversion that the schema applies to 

121 :param str max_version: Maximum API microversion that the schema applies to 

122 """ 

123 

124 def add_validator(func): 

125 @functools.wraps(func) 

126 def wrapper(*args, **kwargs): 

127 _schema_validation_helper( 

128 request_body_schema, 

129 kwargs['body'], 

130 min_version, 

131 max_version, 

132 args, 

133 kwargs 

134 ) 

135 return func(*args, **kwargs) 

136 

137 wrapper._request_schema = request_body_schema 

138 

139 return wrapper 

140 

141 return add_validator 

142 

143 

144def response_body_schema( 

145 response_body_schema: ty.Dict[str, ty.Any], 

146 min_version: ty.Optional[str] = None, 

147 max_version: ty.Optional[str] = None, 

148): 

149 """Register a schema to validate response body. 

150 

151 Registered schema will be used for validating response body just after 

152 API method executing. 

153 

154 :param dict response_body_schema: a schema to validate response body 

155 :param str min_version: Minimum API microversion that the schema applies to 

156 :param str max_version: Maximum API microversion that the schema applies to 

157 """ 

158 

159 def add_validator(func): 

160 @functools.wraps(func) 

161 def wrapper(*args, **kwargs): 

162 response = func(*args, **kwargs) 

163 

164 if CONF.api.response_validation == 'ignore': 164 ↛ 167line 164 didn't jump to line 167 because the condition on line 164 was never true

165 # don't waste our time checking anything if we're ignoring 

166 # schema errors 

167 return response 

168 

169 # NOTE(stephenfin): If our response is an object, we need to 

170 # serializer and deserialize to convert e.g. date-time to strings 

171 if isinstance(response, wsgi.ResponseObject): 

172 serializer = wsgi.JSONDictSerializer() 

173 _body = serializer.serialize(response.obj) 

174 # TODO(stephenfin): We should replace all instances of this with 

175 # wsgi.ResponseObject 

176 elif isinstance(response, webob.Response): 

177 _body = response.body 

178 else: 

179 serializer = wsgi.JSONDictSerializer() 

180 _body = serializer.serialize(response) 

181 

182 if _body == b'': 

183 body = None 

184 else: 

185 body = jsonutils.loads(_body) 

186 

187 try: 

188 _schema_validation_helper( 

189 response_body_schema, 

190 body, 

191 min_version, 

192 max_version, 

193 args, 

194 kwargs 

195 ) 

196 except exception.ValidationError: 

197 if CONF.api.response_validation == 'warn': 

198 LOG.exception('Schema failed to validate') 

199 else: 

200 raise 

201 return response 

202 

203 wrapper._response_schema = response_body_schema 

204 

205 return wrapper 

206 

207 return add_validator 

208 

209 

210def _strip_additional_query_parameters(schema, req): 

211 """Strip the additional properties from the req.GET. 

212 

213 This helper method assumes the JSON-Schema is only described as a dict 

214 without nesting. This method should be called after query parameters pass 

215 the JSON-Schema validation. It also means this method only can be called 

216 after _schema_validation_helper return `True`. 

217 """ 

218 additional_properties = schema.get('additionalProperties', True) 

219 pattern_regexes = [] 

220 

221 patterns = schema.get('patternProperties', None) 

222 if patterns: 

223 for regex in patterns: 

224 pattern_regexes.append(re.compile(regex)) 

225 

226 if additional_properties: 

227 # `req.GET.keys` will return duplicated keys for multiple values 

228 # parameters. To get rid of duplicated keys, convert it to set. 

229 for param in set(req.GET.keys()): 

230 if param not in schema['properties'].keys(): 

231 # keys that can match the patternProperties will be 

232 # retained and handle latter. 

233 if not (list(regex for regex in pattern_regexes if 

234 regex.match(param))): 

235 del req.GET[param] 

236 

237 

238def query_schema(query_params_schema, min_version=None, 

239 max_version=None): 

240 """Register a schema to validate request query parameters. 

241 

242 Registered schema will be used for validating request query params just 

243 before API method executing. 

244 

245 :param query_params_schema: A dict, the JSON-Schema for validating the 

246 query parameters. 

247 :param min_version: A string of two numerals. X.Y indicating the minimum 

248 version of the JSON-Schema to validate against. 

249 :param max_version: A string of two numerals. X.Y indicating the maximum 

250 version of the JSON-Schema against to. 

251 """ 

252 

253 def add_validator(func): 

254 @functools.wraps(func) 

255 def wrapper(*args, **kwargs): 

256 # The request object is always the second argument. 

257 # However numerous unittests pass in the request object 

258 # via kwargs instead so we handle that as well. 

259 # TODO(cyeoh): cleanup unittests so we don't have to 

260 # to do this 

261 if 'req' in kwargs: 

262 req = kwargs['req'] 

263 else: 

264 req = args[1] 

265 

266 # NOTE(Kevin_Zheng): The webob package throws UnicodeError when 

267 # param cannot be decoded. Catch this and raise HTTP 400. 

268 

269 try: 

270 query_dict = req.GET.dict_of_lists() 

271 except UnicodeDecodeError: 

272 msg = _('Query string is not UTF-8 encoded') 

273 raise exception.ValidationError(msg) 

274 

275 if _schema_validation_helper(query_params_schema, 

276 query_dict, 

277 min_version, max_version, 

278 args, kwargs, is_body=False): 

279 # NOTE(alex_xu): The additional query parameters were stripped 

280 # out when `additionalProperties=True`. This is for backward 

281 # compatible with v2.1 API and legacy v2 API. But it makes the 

282 # system more safe for no more unexpected parameters pass down 

283 # to the system. In microversion 2.75, we have blocked all of 

284 # those additional parameters. 

285 _strip_additional_query_parameters(query_params_schema, req) 

286 return func(*args, **kwargs) 

287 

288 wrapper._query_schema = query_params_schema 

289 

290 return wrapper 

291 

292 return add_validator