Coverage for nova/api/openstack/wsgi.py: 93%

493 statements  

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

1# Copyright 2013 IBM Corp. 

2# Copyright 2011 OpenStack Foundation 

3# All Rights Reserved. 

4# 

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

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

7# a copy of the License at 

8# 

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

10# 

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

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

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

14# License for the specific language governing permissions and limitations 

15# under the License. 

16 

17import functools 

18import typing as ty 

19 

20import microversion_parse 

21from oslo_log import log as logging 

22from oslo_serialization import jsonutils 

23from oslo_utils import encodeutils 

24from oslo_utils import strutils 

25import webob 

26 

27from nova.api.openstack import api_version_request as api_version 

28from nova.api.openstack import versioned_method 

29from nova.api import wsgi 

30from nova import exception 

31from nova import i18n 

32from nova.i18n import _ 

33from nova import version 

34 

35 

36LOG = logging.getLogger(__name__) 

37 

38_SUPPORTED_CONTENT_TYPES = ( 

39 'application/json', 

40 'application/vnd.openstack.compute+json', 

41) 

42 

43# These are typically automatically created by routes as either defaults 

44# collection or member methods. 

45_ROUTES_METHODS = [ 

46 'create', 

47 'delete', 

48 'show', 

49 'update', 

50] 

51 

52_METHODS_WITH_BODY = [ 

53 'POST', 

54 'PUT', 

55] 

56 

57# The default api version request if none is requested in the headers 

58# Note(cyeoh): This only applies for the v2.1 API once microversions 

59# support is fully merged. It does not affect the V2 API. 

60DEFAULT_API_VERSION = "2.1" 

61 

62# name of attribute to keep version method information 

63VER_METHOD_ATTR = 'versioned_methods' 

64 

65# Names of headers used by clients to request a specific version 

66# of the REST API 

67API_VERSION_REQUEST_HEADER = 'OpenStack-API-Version' 

68LEGACY_API_VERSION_REQUEST_HEADER = 'X-OpenStack-Nova-API-Version' 

69 

70 

71ENV_LEGACY_V2 = 'openstack.legacy_v2' 

72 

73 

74def get_supported_content_types(): 

75 return _SUPPORTED_CONTENT_TYPES 

76 

77 

78class Request(wsgi.Request): 

79 """Add some OpenStack API-specific logic to the base webob.Request.""" 

80 

81 def __init__(self, *args, **kwargs): 

82 super(Request, self).__init__(*args, **kwargs) 

83 if not hasattr(self, 'api_version_request'): 

84 self.api_version_request = api_version.APIVersionRequest() 

85 

86 def best_match_content_type(self): 

87 """Determine the requested response content-type.""" 

88 if 'nova.best_content_type' not in self.environ: 

89 # Calculate the best MIME type 

90 content_type = None 

91 

92 # Check URL path suffix 

93 parts = self.path.rsplit('.', 1) 

94 if len(parts) > 1: 

95 possible_type = 'application/' + parts[1] 

96 if possible_type in get_supported_content_types(): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true

97 content_type = possible_type 

98 

99 if not content_type: 99 ↛ 105line 99 didn't jump to line 105 because the condition on line 99 was always true

100 best_matches = self.accept.acceptable_offers( 

101 get_supported_content_types()) 

102 if best_matches: 

103 content_type = best_matches[0][0] 

104 

105 self.environ['nova.best_content_type'] = (content_type or 

106 'application/json') 

107 

108 return self.environ['nova.best_content_type'] 

109 

110 def get_content_type(self): 

111 """Determine content type of the request body. 

112 

113 Does not do any body introspection, only checks header 

114 

115 """ 

116 if "Content-Type" not in self.headers: 

117 return None 

118 

119 content_type = self.content_type 

120 

121 # NOTE(markmc): text/plain is the default for eventlet and 

122 # other webservers which use mimetools.Message.gettype() 

123 # whereas twisted defaults to ''. 

124 if not content_type or content_type == 'text/plain': 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 return None 

126 

127 if content_type not in get_supported_content_types(): 

128 raise exception.InvalidContentType(content_type=content_type) 

129 

130 return content_type 

131 

132 def best_match_language(self): 

133 """Determine the best available language for the request. 

134 

135 :returns: the best language match or None if the 'Accept-Language' 

136 header was not available in the request. 

137 """ 

138 if not self.accept_language: 

139 return None 

140 

141 # NOTE(takashin): To decide the default behavior, 'default' is 

142 # preferred over 'default_tag' because that is return as it is when 

143 # no match. This is also little tricky that 'default' value cannot be 

144 # None. At least one of default_tag or default must be supplied as 

145 # an argument to the method, to define the defaulting behavior. 

146 # So passing a sentinel value to return None from this function. 

147 best_match = self.accept_language.lookup( 

148 i18n.get_available_languages(), default='fake_LANG') 

149 

150 if best_match == 'fake_LANG': 

151 best_match = None 

152 return best_match 

153 

154 def set_api_version_request(self): 

155 """Set API version request based on the request header information.""" 

156 hdr_string = microversion_parse.get_version( 

157 self.headers, service_type='compute', 

158 legacy_headers=[LEGACY_API_VERSION_REQUEST_HEADER]) 

159 

160 if hdr_string is None: 

161 self.api_version_request = api_version.APIVersionRequest( 

162 api_version.DEFAULT_API_VERSION) 

163 elif hdr_string == 'latest': 

164 # 'latest' is a special keyword which is equivalent to 

165 # requesting the maximum version of the API supported 

166 self.api_version_request = api_version.max_api_version() 

167 else: 

168 self.api_version_request = api_version.APIVersionRequest( 

169 hdr_string) 

170 

171 # Check that the version requested is within the global 

172 # minimum/maximum of supported API versions 

173 if not self.api_version_request.matches( 

174 api_version.min_api_version(), 

175 api_version.max_api_version()): 

176 raise exception.InvalidGlobalAPIVersion( 

177 req_ver=self.api_version_request.get_string(), 

178 min_ver=api_version.min_api_version().get_string(), 

179 max_ver=api_version.max_api_version().get_string()) 

180 

181 def set_legacy_v2(self): 

182 self.environ[ENV_LEGACY_V2] = True 

183 

184 def is_legacy_v2(self): 

185 return self.environ.get(ENV_LEGACY_V2, False) 

186 

187 

188class ActionDispatcher(object): 

189 """Maps method name to local methods through action name.""" 

190 

191 def dispatch(self, *args, **kwargs): 

192 """Find and call local method.""" 

193 action = kwargs.pop('action', 'default') 

194 action_method = getattr(self, str(action), self.default) 

195 return action_method(*args, **kwargs) 

196 

197 def default(self, data): 

198 raise NotImplementedError() 

199 

200 

201class JSONDeserializer(ActionDispatcher): 

202 

203 def _from_json(self, datastring): 

204 try: 

205 return jsonutils.loads(datastring) 

206 except ValueError: 

207 msg = _("cannot understand JSON") 

208 raise exception.MalformedRequestBody(reason=msg) 

209 

210 def deserialize(self, datastring, action='default'): 

211 return self.dispatch(datastring, action=action) 

212 

213 def default(self, datastring): 

214 return {'body': self._from_json(datastring)} 

215 

216 

217class JSONDictSerializer(ActionDispatcher): 

218 """Default JSON request body serialization.""" 

219 

220 def serialize(self, data, action='default'): 

221 return self.dispatch(data, action=action) 

222 

223 def default(self, data): 

224 return str(jsonutils.dumps(data)) 

225 

226 

227class WSGICodes: 

228 """A microversion-aware WSGI code decorator. 

229 

230 Allow definition and retrieval of WSGI return codes on a microversion-aware 

231 basis. 

232 """ 

233 

234 def __init__(self) -> None: 

235 self._codes: list[tuple[int, ty.Optional[str], ty.Optional[str]]] = [] 

236 

237 def add_code( 

238 self, code: tuple[int, ty.Optional[str], ty.Optional[str]] 

239 ) -> None: 

240 self._codes.append(code) 

241 

242 def __call__(self, req: Request) -> int: 

243 ver = req.api_version_request 

244 

245 for code, min_version, max_version in self._codes: 245 ↛ 251line 245 didn't jump to line 251 because the loop on line 245 didn't complete

246 min_ver = api_version.APIVersionRequest(min_version) 

247 max_ver = api_version.APIVersionRequest(max_version) 

248 if ver.matches(min_ver, max_ver): 248 ↛ 245line 248 didn't jump to line 245 because the condition on line 248 was always true

249 return code 

250 

251 LOG.error("Unknown return code in API method") 

252 msg = _("Unknown return code in API method") 

253 raise webob.exc.HTTPInternalServerError(explanation=msg) 

254 

255 

256def response( 

257 code: int, 

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

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

260): 

261 """Attaches response code to a method. 

262 

263 This decorator associates a response code with a method. Note 

264 that the function attributes are directly manipulated; the method 

265 is not wrapped. 

266 """ 

267 

268 def decorator(func): 

269 if not hasattr(func, 'wsgi_codes'): 269 ↛ 271line 269 didn't jump to line 271 because the condition on line 269 was always true

270 func.wsgi_codes = WSGICodes() 

271 func.wsgi_codes.add_code((code, min_version, max_version)) 

272 return func 

273 return decorator 

274 

275 

276class ResponseObject(object): 

277 """Bundles a response object 

278 

279 Object that app methods may return in order to allow its response 

280 to be modified by extensions in the code. Its use is optional (and 

281 should only be used if you really know what you are doing). 

282 """ 

283 

284 def __init__(self, obj, code=None, headers=None): 

285 """Builds a response object.""" 

286 

287 self.obj = obj 

288 self._default_code = 200 

289 self._code = code 

290 self._headers = headers or {} 

291 self.serializer = JSONDictSerializer() 

292 

293 def __getitem__(self, key): 

294 """Retrieves a header with the given name.""" 

295 

296 return self._headers[key.lower()] 

297 

298 def __setitem__(self, key, value): 

299 """Sets a header with the given name to the given value.""" 

300 

301 self._headers[key.lower()] = value 

302 

303 def __delitem__(self, key): 

304 """Deletes the header with the given name.""" 

305 

306 del self._headers[key.lower()] 

307 

308 def serialize(self, request, content_type): 

309 """Serializes the wrapped object. 

310 

311 Utility method for serializing the wrapped object. Returns a 

312 webob.Response object. 

313 

314 Header values are set to the appropriate Python type and 

315 encoding demanded by PEP 3333: whatever the native str type is. 

316 """ 

317 

318 serializer = self.serializer 

319 

320 body = None 

321 if self.obj is not None: 

322 body = serializer.serialize(self.obj) 

323 response = webob.Response(body=body) 

324 response.status_int = self.code 

325 for hdr, val in self._headers.items(): 

326 # In Py3.X Headers must be a str that was first safely 

327 # encoded to UTF-8 (to catch any bad encodings) and then 

328 # decoded back to a native str. 

329 response.headers[hdr] = encodeutils.safe_decode( 

330 encodeutils.safe_encode(val)) 

331 # Deal with content_type 

332 if not isinstance(content_type, str): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 content_type = str(content_type) 

334 # In Py3.X Headers must be a str. 

335 response.headers['Content-Type'] = encodeutils.safe_decode( 

336 encodeutils.safe_encode(content_type)) 

337 return response 

338 

339 @property 

340 def code(self): 

341 """Retrieve the response status.""" 

342 

343 return self._code or self._default_code 

344 

345 @property 

346 def headers(self): 

347 """Retrieve the headers.""" 

348 

349 return self._headers.copy() 

350 

351 

352def action_peek(body): 

353 """Determine action to invoke. 

354 

355 This looks inside the json body and fetches out the action method 

356 name. 

357 """ 

358 

359 try: 

360 decoded = jsonutils.loads(body) 

361 except ValueError: 

362 msg = _("cannot understand JSON") 

363 raise exception.MalformedRequestBody(reason=msg) 

364 

365 # Make sure there's exactly one key... 

366 if len(decoded) != 1: 

367 msg = _("too many body keys") 

368 raise exception.MalformedRequestBody(reason=msg) 

369 

370 # Return the action name 

371 return list(decoded.keys())[0] 

372 

373 

374class ResourceExceptionHandler(object): 

375 """Context manager to handle Resource exceptions. 

376 

377 Used when processing exceptions generated by API implementation 

378 methods. Converts most exceptions to Fault 

379 exceptions, with the appropriate logging. 

380 """ 

381 

382 def __enter__(self): 

383 return None 

384 

385 def __exit__(self, ex_type, ex_value, ex_traceback): 

386 if not ex_value: 

387 return True 

388 

389 if isinstance(ex_value, exception.Forbidden): 

390 raise Fault(webob.exc.HTTPForbidden( 

391 explanation=ex_value.format_message())) 

392 elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod): 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true

393 raise 

394 elif isinstance(ex_value, exception.Invalid): 

395 raise Fault(exception.ConvertedException( 

396 code=ex_value.code, 

397 explanation=ex_value.format_message())) 

398 elif isinstance(ex_value, TypeError): 

399 exc_info = (ex_type, ex_value, ex_traceback) 

400 LOG.error('Exception handling resource: %s', ex_value, 

401 exc_info=exc_info) 

402 raise Fault(webob.exc.HTTPBadRequest()) 

403 elif isinstance(ex_value, Fault): 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true

404 LOG.info("Fault thrown: %s", ex_value) 

405 raise ex_value 

406 elif isinstance(ex_value, webob.exc.HTTPException): 

407 LOG.info("HTTP exception thrown: %s", ex_value) 

408 raise Fault(ex_value) 

409 

410 # We didn't handle the exception 

411 return False 

412 

413 

414class Resource(wsgi.Application): 

415 """WSGI app that handles (de)serialization and controller dispatch. 

416 

417 WSGI app that reads routing information supplied by RoutesMiddleware 

418 and calls the requested action method upon its controller. All 

419 controller action methods must accept a 'req' argument, which is the 

420 incoming wsgi.Request. If the operation is a PUT or POST, the controller 

421 method must also accept a 'body' argument (the deserialized request body). 

422 They may raise a webob.exc exception or return a dict, which will be 

423 serialized by requested content type. 

424 

425 Exceptions derived from webob.exc.HTTPException will be automatically 

426 wrapped in Fault() to provide API friendly error responses. 

427 

428 """ 

429 support_api_request_version = True 

430 

431 def __init__(self, controller): 

432 """:param controller: object that implement methods created by routes 

433 lib 

434 """ 

435 self.controller = controller 

436 self.sub_controllers = [] 

437 

438 self.default_serializers = dict(json=JSONDictSerializer) 

439 

440 # Copy over the actions dictionary 

441 self.wsgi_actions = {} 

442 if controller: 

443 self.register_actions(controller) 

444 

445 def register_actions(self, controller): 

446 """Registers controller actions with this resource.""" 

447 

448 actions = getattr(controller, 'wsgi_actions', {}) 

449 for key, method_name in actions.items(): 

450 self.wsgi_actions[key] = getattr(controller, method_name) 

451 

452 def register_subcontroller_actions(self, sub_controller): 

453 """Registers sub-controller actions with this resource.""" 

454 self.sub_controllers.append(sub_controller) 

455 actions = getattr(sub_controller, 'wsgi_actions', {}) 

456 for key, method_name in actions.items(): 

457 self.wsgi_actions[key] = getattr(sub_controller, method_name) 

458 

459 def get_action_args(self, request_environment): 

460 """Parse dictionary created by routes library.""" 

461 

462 # NOTE(Vek): Check for get_action_args() override in the 

463 # controller 

464 if hasattr(self.controller, 'get_action_args'): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 return self.controller.get_action_args(request_environment) 

466 

467 try: 

468 args = request_environment['wsgiorg.routing_args'][1].copy() 

469 except (KeyError, IndexError, AttributeError): 

470 return {} 

471 

472 try: 

473 del args['controller'] 

474 except KeyError: 

475 pass 

476 

477 try: 

478 del args['format'] 

479 except KeyError: 

480 pass 

481 

482 return args 

483 

484 def get_body(self, request): 

485 content_type = request.get_content_type() 

486 

487 return content_type, request.body 

488 

489 def deserialize(self, body): 

490 return JSONDeserializer().deserialize(body) 

491 

492 def _should_have_body(self, request): 

493 return request.method in _METHODS_WITH_BODY 

494 

495 @webob.dec.wsgify(RequestClass=Request) 

496 def __call__(self, request): 

497 """WSGI method that controls (de)serialization and method dispatch.""" 

498 

499 if self.support_api_request_version: 

500 # Set the version of the API requested based on the header 

501 try: 

502 request.set_api_version_request() 

503 except exception.InvalidAPIVersionString as e: 

504 return Fault(webob.exc.HTTPBadRequest( 

505 explanation=e.format_message())) 

506 except exception.InvalidGlobalAPIVersion as e: 

507 return Fault(webob.exc.HTTPNotAcceptable( 

508 explanation=e.format_message())) 

509 

510 # Identify the action, its arguments, and the requested 

511 # content type 

512 action_args = self.get_action_args(request.environ) 

513 action = action_args.pop('action', None) 

514 

515 # NOTE(sdague): we filter out InvalidContentTypes early so we 

516 # know everything is good from here on out. 

517 try: 

518 content_type, body = self.get_body(request) 

519 accept = request.best_match_content_type() 

520 except exception.InvalidContentType: 

521 msg = _("Unsupported Content-Type") 

522 return Fault(webob.exc.HTTPUnsupportedMediaType(explanation=msg)) 

523 

524 # NOTE(Vek): Splitting the function up this way allows for 

525 # auditing by external tools that wrap the existing 

526 # function. If we try to audit __call__(), we can 

527 # run into troubles due to the @webob.dec.wsgify() 

528 # decorator. 

529 return self._process_stack(request, action, action_args, 

530 content_type, body, accept) 

531 

532 def _process_stack(self, request, action, action_args, 

533 content_type, body, accept): 

534 """Implement the processing stack.""" 

535 

536 # Get the implementing method 

537 try: 

538 meth = self.get_method(request, action, 

539 content_type, body) 

540 except (AttributeError, TypeError): 

541 return Fault(webob.exc.HTTPNotFound()) 

542 except KeyError as ex: 

543 msg = _("There is no such action: %s") % ex.args[0] 

544 return Fault(webob.exc.HTTPBadRequest(explanation=msg)) 

545 except exception.MalformedRequestBody: 

546 msg = _("Malformed request body") 

547 return Fault(webob.exc.HTTPBadRequest(explanation=msg)) 

548 

549 if body: 

550 msg = _("Action: '%(action)s', calling method: %(meth)s, body: " 

551 "%(body)s") % {'action': action, 

552 'body': str(body, 'utf-8'), 

553 'meth': str(meth)} 

554 LOG.debug(strutils.mask_password(msg)) 

555 else: 

556 LOG.debug("Calling method '%(meth)s'", 

557 {'meth': str(meth)}) 

558 

559 # Now, deserialize the request body... 

560 try: 

561 contents = self._get_request_content(body, request) 

562 except exception.MalformedRequestBody: 

563 msg = _("Malformed request body") 

564 return Fault(webob.exc.HTTPBadRequest(explanation=msg)) 

565 

566 # Update the action args 

567 action_args.update(contents) 

568 

569 project_id = action_args.pop("project_id", None) 

570 context = request.environ.get('nova.context') 

571 if (context and project_id and (project_id != context.project_id)): 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true

572 msg = _("Malformed request URL: URL's project_id '%(project_id)s'" 

573 " doesn't match Context's project_id" 

574 " '%(context_project_id)s'") % \ 

575 {'project_id': project_id, 

576 'context_project_id': context.project_id} 

577 return Fault(webob.exc.HTTPBadRequest(explanation=msg)) 

578 

579 response = None 

580 try: 

581 with ResourceExceptionHandler(): 

582 action_result = self.dispatch(meth, request, action_args) 

583 except Fault as ex: 

584 response = ex 

585 

586 if not response: 

587 # No exceptions; convert action_result into a 

588 # ResponseObject 

589 resp_obj = None 

590 if type(action_result) is dict or action_result is None: 

591 resp_obj = ResponseObject(action_result) 

592 elif isinstance(action_result, ResponseObject): 

593 resp_obj = action_result 

594 else: 

595 response = action_result 

596 

597 # Run post-processing extensions 

598 if resp_obj: 

599 # Do a preserialize to set up the response object 

600 if hasattr(meth, 'wsgi_codes'): 

601 resp_obj._default_code = meth.wsgi_codes(request) 

602 

603 if resp_obj and not response: 

604 response = resp_obj.serialize(request, accept) 

605 

606 if hasattr(response, 'headers'): 

607 for hdr, val in list(response.headers.items()): 

608 if not isinstance(val, str): 

609 val = str(val) 

610 # In Py3.X Headers must be a string 

611 response.headers[hdr] = encodeutils.safe_decode( 

612 encodeutils.safe_encode(val)) 

613 

614 if not request.api_version_request.is_null(): 

615 response.headers[API_VERSION_REQUEST_HEADER] = \ 

616 'compute ' + request.api_version_request.get_string() 

617 response.headers[LEGACY_API_VERSION_REQUEST_HEADER] = \ 

618 request.api_version_request.get_string() 

619 response.headers.add('Vary', API_VERSION_REQUEST_HEADER) 

620 response.headers.add('Vary', LEGACY_API_VERSION_REQUEST_HEADER) 

621 

622 return response 

623 

624 def _get_request_content(self, body, request): 

625 contents = {} 

626 if self._should_have_body(request): 

627 # allow empty body with PUT and POST 

628 if request.content_length == 0 or request.content_length is None: 

629 contents = {'body': None} 

630 else: 

631 contents = self.deserialize(body) 

632 return contents 

633 

634 def get_method(self, request, action, content_type, body): 

635 meth = self._get_method(request, 

636 action, 

637 content_type, 

638 body) 

639 return meth 

640 

641 def _get_method(self, request, action, content_type, body): 

642 """Look up the action-specific method.""" 

643 # Look up the method 

644 try: 

645 if not self.controller: 

646 meth = getattr(self, action) 

647 else: 

648 meth = getattr(self.controller, action) 

649 return meth 

650 except AttributeError: 

651 if (not self.wsgi_actions or 

652 action not in _ROUTES_METHODS + ['action']): 

653 # Propagate the error 

654 raise 

655 if action == 'action': 

656 action_name = action_peek(body) 

657 else: 

658 action_name = action 

659 

660 # Look up the action method 

661 return (self.wsgi_actions[action_name]) 

662 

663 def dispatch(self, method, request, action_args): 

664 """Dispatch a call to the action-specific method.""" 

665 

666 try: 

667 return method(req=request, **action_args) 

668 except exception.VersionNotFoundForAPIMethod: 

669 # We deliberately don't return any message information 

670 # about the exception to the user so it looks as if 

671 # the method is simply not implemented. 

672 return Fault(webob.exc.HTTPNotFound()) 

673 

674 

675def action(name): 

676 """Mark a function as an action. 

677 

678 The given name will be taken as the action key in the body. 

679 

680 This is also overloaded to allow extensions to provide 

681 non-extending definitions of create and delete operations. 

682 """ 

683 

684 def decorator(func): 

685 func.wsgi_action = name 

686 return func 

687 return decorator 

688 

689 

690def removed(version: str, reason: str): 

691 """Mark a function as removed. 

692 

693 The given reason will be stored as an attribute of the function. 

694 """ 

695 def decorator(func): 

696 func.removed = True 

697 func.removed_version = reason 

698 func.removed_reason = reason 

699 return func 

700 return decorator 

701 

702 

703def expected_errors( 

704 errors: ty.Union[int, tuple[int, ...]], 

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

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

707): 

708 """Decorator for v2.1 API methods which specifies expected exceptions. 

709 

710 Specify which exceptions may occur when an API method is called. If an 

711 unexpected exception occurs then return a 500 instead and ask the user 

712 of the API to file a bug report. 

713 """ 

714 def decorator(f): 

715 @functools.wraps(f) 

716 def wrapped(*args, **kwargs): 

717 min_ver = api_version.APIVersionRequest(min_version) 

718 max_ver = api_version.APIVersionRequest(max_version) 

719 

720 # The request object is always the second argument. 

721 # However numerous unittests pass in the request object 

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

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

724 # to do this 

725 if 'req' in kwargs: 

726 ver = kwargs['req'].api_version_request 

727 else: 

728 ver = args[1].api_version_request 

729 

730 try: 

731 return f(*args, **kwargs) 

732 except Exception as exc: 

733 # if this instance of the decorator is intended for other 

734 # versions, let the exception bubble up as-is 

735 if not ver.matches(min_ver, max_ver): 

736 raise 

737 

738 if isinstance(exc, webob.exc.WSGIHTTPException): 

739 if isinstance(errors, int): 

740 t_errors = (errors,) 

741 else: 

742 t_errors = errors 

743 if exc.code in t_errors: 

744 raise 

745 elif isinstance(exc, exception.Forbidden): 

746 # Note(cyeoh): Special case to handle 

747 # Forbidden exceptions so every 

748 # extension method does not need to wrap authorize 

749 # calls. ResourceExceptionHandler silently 

750 # converts NotAuthorized to HTTPForbidden 

751 raise 

752 elif isinstance(exc, exception.NotSupported): 

753 # Note(gmann): Special case to handle 

754 # NotSupported exceptions. We want to raise 400 BadRequest 

755 # for the NotSupported exception which is basically used 

756 # to raise for not supported features. Converting it here 

757 # will avoid converting every NotSupported inherited 

758 # exception in API controller. 

759 raise webob.exc.HTTPBadRequest( 

760 explanation=exc.format_message()) 

761 elif isinstance(exc, exception.ValidationError): 

762 # Note(oomichi): Handle a validation error, which 

763 # happens due to invalid API parameters, as an 

764 # expected error. 

765 raise 

766 elif isinstance(exc, exception.Unauthorized): 766 ↛ 771line 766 didn't jump to line 771 because the condition on line 766 was never true

767 # Handle an authorized exception, will be 

768 # automatically converted to a HTTP 401, clients 

769 # like python-novaclient handle this error to 

770 # generate new token and do another attempt. 

771 raise 

772 

773 LOG.exception("Unexpected exception in API method") 

774 msg = _("Unexpected API Error. " 

775 "{support}\n{exc}").format( 

776 support=version.support_string(), 

777 exc=type(exc)) 

778 raise webob.exc.HTTPInternalServerError(explanation=msg) 

779 

780 return wrapped 

781 

782 return decorator 

783 

784 

785class ControllerMetaclass(type): 

786 """Controller metaclass. 

787 

788 This metaclass automates the task of assembling a dictionary 

789 mapping action keys to method names. 

790 """ 

791 

792 def __new__(mcs, name, bases, cls_dict): 

793 """Adds the wsgi_actions dictionary to the class.""" 

794 

795 # Find all actions 

796 actions = {} 

797 versioned_methods = None 

798 # start with wsgi actions from base classes 

799 for base in bases: 

800 actions.update(getattr(base, 'wsgi_actions', {})) 

801 

802 if base.__name__ == "Controller": 

803 # NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute 

804 # between API controller class creations. This allows us 

805 # to use a class decorator on the API methods that doesn't 

806 # require naming explicitly what method is being versioned as 

807 # it can be implicit based on the method decorated. It is a bit 

808 # ugly. 

809 if VER_METHOD_ATTR in base.__dict__: 

810 versioned_methods = getattr(base, VER_METHOD_ATTR) 

811 delattr(base, VER_METHOD_ATTR) 

812 

813 for key, value in cls_dict.items(): 

814 if not callable(value): 

815 continue 

816 if getattr(value, 'wsgi_action', None): 

817 actions[value.wsgi_action] = key 

818 

819 # Add the actions to the class dict 

820 cls_dict['wsgi_actions'] = actions 

821 if versioned_methods: 

822 cls_dict[VER_METHOD_ATTR] = versioned_methods 

823 

824 return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, 

825 cls_dict) 

826 

827 

828class Controller(metaclass=ControllerMetaclass): 

829 """Default controller.""" 

830 

831 _view_builder_class = None 

832 

833 def __init__(self): 

834 """Initialize controller with a view builder instance.""" 

835 if self._view_builder_class: 

836 self._view_builder = self._view_builder_class() 

837 else: 

838 self._view_builder = None 

839 

840 def __getattribute__(self, key): 

841 

842 def version_select(*args, **kwargs): 

843 """Look for the method which matches the name supplied and version 

844 constraints and calls it with the supplied arguments. 

845 

846 @return: Returns the result of the method called 

847 @raises: VersionNotFoundForAPIMethod if there is no method which 

848 matches the name and version constraints 

849 """ 

850 

851 # The first arg to all versioned methods is always the request 

852 # object. The version for the request is attached to the 

853 # request object 

854 if len(args) == 0: 

855 ver = kwargs['req'].api_version_request 

856 else: 

857 ver = args[0].api_version_request 

858 

859 func_list = self.versioned_methods[key] 

860 for func in func_list: 

861 if ver.matches(func.start_version, func.end_version): 

862 # Update the version_select wrapper function so 

863 # other decorator attributes like wsgi.response 

864 # are still respected. 

865 functools.update_wrapper(version_select, func.func) 

866 return func.func(self, *args, **kwargs) 

867 

868 # No version match 

869 raise exception.VersionNotFoundForAPIMethod(version=ver) 

870 

871 try: 

872 version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR) 

873 except AttributeError: 

874 # No versioning on this class 

875 return object.__getattribute__(self, key) 

876 

877 if version_meth_dict and \ 

878 key in object.__getattribute__(self, VER_METHOD_ATTR): 

879 return version_select 

880 

881 return object.__getattribute__(self, key) 

882 

883 # NOTE(cyeoh): This decorator MUST appear first (the outermost 

884 # decorator) on an API method for it to work correctly 

885 @classmethod 

886 def api_version(cls, min_ver, max_ver=None): 

887 """Decorator for versioning api methods. 

888 

889 Add the decorator to any method which takes a request object 

890 as the first parameter and belongs to a class which inherits from 

891 wsgi.Controller. 

892 

893 @min_ver: string representing minimum version 

894 @max_ver: optional string representing maximum version 

895 """ 

896 

897 def decorator(f): 

898 obj_min_ver = api_version.APIVersionRequest(min_ver) 

899 if max_ver: 

900 obj_max_ver = api_version.APIVersionRequest(max_ver) 

901 else: 

902 obj_max_ver = api_version.APIVersionRequest() 

903 

904 # Add to list of versioned methods registered 

905 func_name = f.__name__ 

906 new_func = versioned_method.VersionedMethod( 

907 func_name, obj_min_ver, obj_max_ver, f) 

908 

909 func_dict = getattr(cls, VER_METHOD_ATTR, {}) 

910 if not func_dict: 

911 setattr(cls, VER_METHOD_ATTR, func_dict) 

912 

913 func_list = func_dict.get(func_name, []) 

914 if not func_list: 

915 func_dict[func_name] = func_list 

916 func_list.append(new_func) 

917 # Ensure the list is sorted by minimum version (reversed) 

918 # so later when we work through the list in order we find 

919 # the method which has the latest version which supports 

920 # the version requested. 

921 is_intersect = Controller.check_for_versions_intersection( 

922 func_list) 

923 

924 if is_intersect: 924 ↛ 925line 924 didn't jump to line 925 because the condition on line 924 was never true

925 raise exception.ApiVersionsIntersect( 

926 name=new_func.name, 

927 min_ver=new_func.start_version, 

928 max_ver=new_func.end_version, 

929 ) 

930 

931 func_list.sort(key=lambda f: f.start_version, reverse=True) 

932 

933 return f 

934 

935 return decorator 

936 

937 @staticmethod 

938 def is_valid_body(body, entity_name): 

939 if not (body and entity_name in body): 

940 return False 

941 

942 def is_dict(d): 

943 try: 

944 d.get(None) 

945 return True 

946 except AttributeError: 

947 return False 

948 

949 return is_dict(body[entity_name]) 

950 

951 @staticmethod 

952 def check_for_versions_intersection(func_list): 

953 """Determines whether function list contains version intervals 

954 intersections or not. General algorithm: 

955 

956 https://en.wikipedia.org/wiki/Intersection_algorithm 

957 

958 :param func_list: list of VersionedMethod objects 

959 :return: boolean 

960 """ 

961 pairs = [] 

962 counter = 0 

963 

964 for f in func_list: 

965 pairs.append((f.start_version, 1, f)) 

966 pairs.append((f.end_version, -1, f)) 

967 

968 def compare(x): 

969 return x[0] 

970 

971 pairs.sort(key=compare) 

972 

973 for p in pairs: 

974 counter += p[1] 

975 

976 if counter > 1: 

977 return True 

978 

979 return False 

980 

981 

982class Fault(webob.exc.HTTPException): 

983 """Wrap webob.exc.HTTPException to provide API friendly response.""" 

984 

985 _fault_names = { 

986 400: "badRequest", 

987 401: "unauthorized", 

988 403: "forbidden", 

989 404: "itemNotFound", 

990 405: "badMethod", 

991 409: "conflictingRequest", 

992 413: "overLimit", 

993 415: "badMediaType", 

994 429: "overLimit", 

995 501: "notImplemented", 

996 503: "serviceUnavailable"} 

997 

998 def __init__(self, exception): 

999 """Create a Fault for the given webob.exc.exception.""" 

1000 self.wrapped_exc = exception 

1001 for key, value in list(self.wrapped_exc.headers.items()): 

1002 self.wrapped_exc.headers[key] = str(value) 

1003 self.status_int = exception.status_int 

1004 

1005 @webob.dec.wsgify(RequestClass=Request) 

1006 def __call__(self, req): 

1007 """Generate a WSGI response based on the exception passed to ctor.""" 

1008 

1009 user_locale = req.best_match_language() 

1010 # Replace the body with fault details. 

1011 code = self.wrapped_exc.status_int 

1012 fault_name = self._fault_names.get(code, "computeFault") 

1013 explanation = self.wrapped_exc.explanation 

1014 LOG.debug("Returning %(code)s to user: %(explanation)s", 

1015 {'code': code, 'explanation': explanation}) 

1016 

1017 explanation = i18n.translate(explanation, user_locale) 

1018 fault_data = { 

1019 fault_name: { 

1020 'code': code, 

1021 'message': explanation}} 

1022 if code == 413 or code == 429: 

1023 retry = self.wrapped_exc.headers.get('Retry-After', None) 

1024 if retry: 

1025 fault_data[fault_name]['retryAfter'] = retry 

1026 

1027 if not req.api_version_request.is_null(): 

1028 self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = \ 

1029 'compute ' + req.api_version_request.get_string() 

1030 self.wrapped_exc.headers[LEGACY_API_VERSION_REQUEST_HEADER] = \ 

1031 req.api_version_request.get_string() 

1032 self.wrapped_exc.headers.add('Vary', API_VERSION_REQUEST_HEADER) 

1033 self.wrapped_exc.headers.add('Vary', 

1034 LEGACY_API_VERSION_REQUEST_HEADER) 

1035 

1036 self.wrapped_exc.content_type = 'application/json' 

1037 self.wrapped_exc.charset = 'UTF-8' 

1038 self.wrapped_exc.text = JSONDictSerializer().serialize(fault_data) 

1039 

1040 return self.wrapped_exc 

1041 

1042 def __str__(self): 

1043 return self.wrapped_exc.__str__()