Come up with a common base class for backwards-compatibility translation wrappers between the different API versions #143

This commit is contained in:
Ian Renton
2026-09-24 21:47:09 +01:00
parent 0ce3ca8d29
commit 81166dccab
9 changed files with 419 additions and 459 deletions
@@ -0,0 +1,105 @@
import json
import tornado.web
from tornado_eventsource.handler import EventSourceHandler
from core.utils import safe_json_dumps
class CompatibilityWrapper(tornado.web.RequestHandler):
"""Base class for compatibility wrappers. These provide translation between the different API versions, so while
the code itself only has proper handlers for the latest API version, we can still support the older APIs by wrapping
our API in one of these. The wrapper handles translating the old format of incoming data into the latest format,
then translating the output back into the format that an older client will expect. We actually do this in stages
because the API version change sets sit on top of each other, so for example a v1 request might go through two
wrappers, one to bring it up to v2, and the next to bring it up to v3, then the API call happens, then we go back
down through the wrappers in the other direction."""
def prepare(self):
self.translate_request()
super().prepare()
def translate_request(self):
"""Translate self.request in place from an older API version to the newer one. Overrides should do their own
translation, *then* call super(), so translation proceeds from oldest to newest."""
def translate_response_object(self, obj):
"""Translate a decoded JSON response from the newer API version to the older one. Overrides should call super()
*first*, then do their own translation, so translation proceeds from newest to oldest."""
return obj
def _translate_response(self, chunk):
"""Translate a JSON string output by the newer API version into its older equivalent. Anything that isn't a
JSON string is passed through untouched."""
if not isinstance(chunk, str):
return chunk
try:
return safe_json_dumps(self.translate_response_object(json.loads(chunk)))
except ValueError:
return chunk
class RequestCompatibilityWrapper(CompatibilityWrapper):
"""Compatibility wrapper for normal requests, which translates everything the handler produces."""
def write(self, chunk):
super().write(self._translate_response(chunk))
class StreamCompatibilityWrapper(CompatibilityWrapper, EventSourceHandler):
"""Special case for the SSE stream handlers, which write messages one at a time."""
def write_message(self, name=None, msg=True, wait=None, evt_id=None):
if not name:
msg = self._translate_response(msg)
return super().write_message(name=name, msg=msg, wait=wait, evt_id=evt_id)
def rename_query_params(request, name_map, value_map):
"""Utility method to rename query parameters in the request according to name_map ({old: new}), and rename values
of query parameters according to value_map ({param name: {old: new}}). Comma-separated lists of values are
supported, because we need to handle mapping e.g. "sig=POTA,NO_SIG" to "activity=POTA,NO_ACTIVITY" between v2 and
v3. value_map uses the new parameter names."""
for arguments in (request.arguments, request.query_arguments):
for old_name, new_name in name_map.items():
if old_name in arguments:
arguments[new_name] = arguments.pop(old_name)
for name, values in value_map.items():
if name in arguments:
arguments[name] = [
",".join(values.get(item.strip(), item) for item in v.decode("utf-8").split(",")).encode("utf-8")
for v in arguments[name]
]
def rename_keys_and_values(obj, key_map, value_map):
"""Utility method to rename keys in a JSON object according to key_map ({old: new}), and string values according
to value_map ({old key name: {old: new}}). The object can be list-like or dict-like. This function calls itself
recursively as it goes down the tree of stuff inside a dict."""
if isinstance(obj, dict):
translated = {}
for k, v in obj.items():
if k in value_map and isinstance(v, str):
v = value_map[k].get(v, v)
translated[key_map.get(k, k)] = rename_keys_and_values(v, key_map, value_map)
return translated
if isinstance(obj, list):
return [rename_keys_and_values(i, key_map, value_map) for i in obj]
return obj
def translate_json_body(request, translate):
"""Utility method to replace the request body by decoding it as JSON, passing it through the supplied translate
function, and re-encoding it. This is used to handle the add spot API call where the user is supplying JSON data as
a request body that contains the spot information. If the body is empty or invalid JSON, it is returned as-is so the
handler can return the appropriate error."""
try:
body = json.loads(request.body)
except ValueError:
return
request.body = json.dumps(translate(body)).encode("utf-8")