API routing in Openstack

Image by jordanmadrid / Unsplash

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.

comments powered by Disqus