In the Dalamation (2024.2) cycle, we’re working on adding OpenAPI schemas for a number of the OpenStack services. As part of this effort, I’ve had to learn more than I would like to know about how various services’ API machinery works. The below is a quick summary of how the mapping of URLs (or rather, paths) to API controller methods works in Nova (and Cinder, Manila and other projects that have copied or inherited Nova’s patterns). This is very much inside baseball and probably not useful outside of OpenStack, since we’re using older libraries - namely Routes, Paste, and WebOb - that have been mostly superseded by new libraries like Flask, Falcon, or Starlette. Still, maybe it’s useful for someone.
The main entry point for routing in Nova is the APIRouterV21
class. APIRouterV21
provides mappings
of URLs (or rather, URL paths) to methods of Resource
objects using routes.middleware.RoutesMiddleware
as the ultimate dispatcher. A Resource
object is a wrapper around some Controller
objects: a main
controller and zero or more sub-controllers. If you look at nova/api/openstack/compute/routes.py
you’ll
see a whole load of functools.partial
calls where we create Resource
objects via calls to _create_controller
:
flavor_controller = functools.partial(_create_controller,
flavors.FlavorsController,
[
flavor_access.FlavorActionController
]
)
Here, flavors.FlavorsController
is the “main” controller and flavor_access.FlavorActionController
is a “sub” (or
“action”/“child”) controller. The sub-controller extends the main controller by adding new APIs or actions and to the
best of my knowledge it is legacy from the days where we had API extensions.
These wrapped controllers are then mapped to paths latter in the same file:
ROUTE_LIST = (
# ...
('/flavors/{id}', {
'GET': [flavor_controller, 'show'],
'PUT': [flavor_controller, 'update'],
'DELETE': [flavor_controller, 'delete']
}),
('/flavors/{id}/action', {
'POST': [flavor_controller, 'action']
}),
# ...
)
class APIRouterV21(base_wsgi.Router):
# ...
def __init__(self, custom_routes=None):
super().__init__()
# ...
for path, methods in ROUTE_LIST + custom_routes:
# register route to mapper
# ...
Now with that knowledge, you can run this script locally to see the generated path-method mappings:
from nova.api.openstack import compute
from oslo_config import cfg
from nova.tests import fixtures
CONF = cfg.CONF
fixtures.ConfFixture(CONF).setUp()
fixtures.RPCFixture('nova.test').setUp()
router = compute.APIRouterV21()
count = 0
for route in router.map.matchlist:
if 'controller' not in route.defaults:
continue
controller = route.defaults['controller']
if controller.wsgi_actions == {}:
continue
for action, method in controller.wsgi_actions.items():
print(f' action: {action}, method: {method}')
if 'version_select' in str(method):
count += 1
print(f'useless versioned method count: {count}')
All that this is doing is configuring enough config-related fixtures to allow us to create an APIRouterV21
instance so
that we can iterate through the path-controller mappings it has. If you run this, you’ll see a whole load of output
finishing in this:
useless versioned method count: 288
This is a count of the number of actions that resolve to version_select
methods. version_select
methods are not the
“real” method and are instead wrappers around the real methods (potentially plural, depending on amount of
microversioned revisions) that allow us to handle API versioning. This wrapper methods are useless to us in the OpenAPI
work because we need to get attributes of the real methods - namely, some private attributes we’re using to store the
schema used for a given method. The way to find the “real” method is to look at the versioned_methods
attribute of a
Controller, which contains a mapping of method name to real methods. If you change the above for loop you can see this:
count = 0
for route in router.map.matchlist:
if 'controller' not in route.defaults:
continue
controller = route.defaults['controller']
if controller.wsgi_actions == {}:
continue
for action, method in controller.wsgi_actions.items():
method_name = controller.controller.wsgi_actions.get(action)
if method_name:
versioned_methods = getattr(
controller.controller, 'versioned_methods', {}
).get(method_name)
if versioned_methods:
method = versioned_methods[0].func
print(f' action: {action}, method: {method}')
if 'version_select' in str(method):
count += 1
print(f'useless versioned method count: {count}')
Now if you run this, you’ll get a reduced count:
useless versioned method count: 224
However, it’s still not 0. This is because we’re not able to resolve to all “real” methods using only the “main”
controller alone. To do that, we need the versioned_methods
attribute of the sub-controllers also, or to use the case
of the controllers we gave at the start, the versioned_methods
attribute of both the FlavorsController
controller
and the FlavorActionController
controller. However, we currently have no reference to FlavorActionController
(or any
other sub controller) so we can’t do this.
A patch I’ve proposed fixes this so that we store the reference to FlavorActionController
(and any other
sub-controller) under Resource.sub_controllers
, thus giving us a mechanism to retrieve versioned_methods
attributes
(and any other attribute we might need later) from these sub-controller. With this patch applied, we can change the for
loop further:
count = 0
for route in router.map.matchlist:
if 'controller' not in route.defaults:
continue
controller = route.defaults['controller']
if controller.wsgi_actions == {}:
continue
for action, method in controller.wsgi_actions.items():
main_controller = controller.controller
method_name = main_controller.wsgi_actions.get(action)
if method_name:
versioned_methods = getattr(
main_controller, 'versioned_methods', {}
).get(method_name)
if versioned_methods:
method = versioned_methods[0].func
for sub_controller in controller.sub_controllers:
method_name = sub_controller.wsgi_actions.get(action)
if method_name:
versioned_methods = getattr(
sub_controller, 'versioned_methods', {}
).get(method_name)
if versioned_methods:
method = versioned_methods[0].func
print(f' action: {action}, method: {method}')
if 'version_select' in str(method):
count += 1
print(f'useless versioned method count: {count}')
With this done, we finally get a 0 count:
useless versioned method count: 0
This patch therefore means we’ll now be able to inspect elements of the various controller methods. We’re planning to use this as part of the OpenAPI effort to associate a schema with a method so we can ensure all API methods have schemas.