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
+1
View File
@@ -222,6 +222,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
# we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning:
spot.source = "API"
spot.infer_missing()
self._spots.set(spot.id, spot)
@@ -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")
@@ -0,0 +1,113 @@
from webserver.handlers.api.compatibility.compatibility import (
CompatibilityWrapper,
rename_keys_and_values,
translate_json_body,
)
from webserver.handlers.api.compatibility.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APIDxStatsHandler,
V2APILookupCallHandler,
V2APILookupGridHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISolarConditionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
# QRZ/HamQTH credentials were provided as query parameters in v1, but as headers in v2
_V1_QUERY_PARAMS_TO_V2_HEADERS = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
# DX location source of "GRID" from a v2 response becomes "SPOT" to a v1 client.
_V2_TO_V1_RESPONSE_VALUES = {
"dx_location_source": {"GRID": "SPOT"},
}
class V1CompatibilityWrapper(CompatibilityWrapper):
"""Translates v1 requests to v2 on the way in, and v2 responses to v1 on the way out. This logic captures the
majority of changes needed for each endpoint."""
def translate_request(self):
"""This one is a bit more than a simple translation of query params beceause we also need to move some query
params to instead be headers."""
for param, header in _V1_QUERY_PARAMS_TO_V2_HEADERS.items():
if header in self.request.headers:
continue
value = self.get_query_argument(param, default=None)
if value:
self.request.headers[header] = value
super().translate_request()
def translate_response_object(self, obj):
obj = super().translate_response_object(obj)
return rename_keys_and_values(obj, {}, _V2_TO_V1_RESPONSE_VALUES)
class V1APISpotsHandler(V1CompatibilityWrapper, V2APISpotsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISpotsStreamHandler(V1CompatibilityWrapper, V2APISpotsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIAlertsHandler(V1CompatibilityWrapper, V2APIAlertsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIAlertsStreamHandler(V1CompatibilityWrapper, V2APIAlertsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISolarConditionsHandler(V1CompatibilityWrapper, V2APISolarConditionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIDxStatsHandler(V1CompatibilityWrapper, V2APIDxStatsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIOptionsHandler(V1CompatibilityWrapper, V2APIOptionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIStatusHandler(V1CompatibilityWrapper, V2APIStatusHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupCallHandler(V1CompatibilityWrapper, V2APILookupCallHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupSigRefHandler(V1CompatibilityWrapper, V2APILookupSigRefHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupGridHandler(V1CompatibilityWrapper, V2APILookupGridHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISpotHandler(V1CompatibilityWrapper, V2APISpotHandler):
"""Some special handling required for this one, the v1 add spot call took its data in from query parameters, but in
v2 we changed that to using a request body with the data in, so we need to recreate that here before we pass on
handling to the v2 call."""
def translate_request(self):
def translate(body):
if isinstance(body, dict):
return {"spot": body}
return body
translate_json_body(self.request, translate)
super().translate_request()
@@ -0,0 +1,132 @@
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.compatibility.compatibility import (
CompatibilityWrapper,
RequestCompatibilityWrapper,
StreamCompatibilityWrapper,
rename_keys_and_values,
rename_query_params,
translate_json_body,
)
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import APILookupActivityRefHandler, APILookupCallHandler, APILookupGridHandler
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
# Query parameters renamed in v3
_V2_TO_V3_QUERY_PARAMS = {
"sig": "activity",
"needs_sig": "needs_activity",
"needs_sig_ref": "needs_activity_ref",
}
# Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"},
"fields": {"sig": "activity", "sig_refs": "activity_refs"},
}
# Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = {
"activity": "sig",
"activity_refs": "sig_refs",
"activity_type": "sig_type",
"activities": "sigs",
"activity_ref_data_providers": "sig_ref_data_providers",
"activity_name": "sig_name",
}
# Values of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_VALUES = {
"dx_location_source": {"ACTIVITY REF LOOKUP": "SIG REF LOOKUP"},
}
class V2CompatibilityWrapper(CompatibilityWrapper):
"""Translates v2 requests to v3 on the way in, and v3 responses to v2 on the way out. This logic captures the
majority of changes needed for each endpoint."""
def translate_request(self):
rename_query_params(self.request, _V2_TO_V3_QUERY_PARAMS, _V2_TO_V3_QUERY_VALUES)
super().translate_request()
def translate_response_object(self, obj):
obj = super().translate_response_object(obj)
return rename_keys_and_values(obj, _V3_TO_V2_RESPONSE_KEYS, _V3_TO_V2_RESPONSE_VALUES)
class V2APISpotsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISpotsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISpotsStreamHandler(V2CompatibilityWrapper, StreamCompatibilityWrapper, APISpotsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIAlertsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsStreamHandler(V2CompatibilityWrapper, StreamCompatibilityWrapper, APIAlertsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISolarConditionsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISolarConditionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIDxStatsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIDxStatsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIOptionsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIOptionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIStatusHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIStatusHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupCallHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupCallHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupSigRefHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupActivityRefHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupGridHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupGridHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISpotHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISpotHandler):
"""Some special handling for add spot, because unlike the other calls we have to deal with a request body.
Because the definition of a spot changed (e.g. sig to activity) we need to apply the same translation to
spots that are coming in via the add spot call."""
def translate_request(self):
def translate(body):
if isinstance(body, dict) and isinstance(body.get("spot"), dict):
body["spot"] = self._translate_v2_spot(body["spot"])
return body
translate_json_body(self.request, translate)
super().translate_request()
def _translate_v2_spot(self, spot_data):
"""Translate a spot provided by a client calling the add spot method in v2 format into v3 format"""
spot_data = dict(spot_data)
if "sig" in spot_data:
spot_data["activity"] = spot_data.pop("sig")
if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list):
refs = []
for ref in spot_data["activity_refs"]:
if isinstance(ref, dict) and "sig" in ref:
ref = dict(ref)
ref["activity"] = ref.pop("sig")
refs.append(ref)
spot_data["activity_refs"] = refs
return spot_data
-144
View File
@@ -1,144 +0,0 @@
import logging
import re
from typing import Any
import tornado
from tornado import httputil
from tornado.web import Application
from core.activity_utils import get_ref_regex_for_activity
from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
from webserver.handlers.api.v2_compatibility import translate_v2_spot
logger = logging.getLogger(__name__)
class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in Spothole v2 onwards for backwards
compatibility."""
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
super().__init__(application, request, **kwargs)
def initialize(self, spots):
self._spots = spots
def post(self):
try:
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if format not json
if not self.request.headers.get("Content-Type", "").startswith("application/json"):
self.set_status(415)
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if request body is empty
post_data = self.request.body
if not post_data:
self.set_status(422)
self.write(safe_json_dumps("Error - request body is empty"))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Read in the request body as JSON then convert to a Spot object. The v1 spot format uses the same field
# names as v2, so needs the same translation to v3 field names.
json_spot = translate_v2_spot(tornado.escape.json_decode(post_data))
spot = Spot(**json_spot)
# Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422)
self.write(
safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.")
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if frequency not in a known band
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422)
self.write(safe_json_dumps(f"Error - Frequency of {spot.freq / 1000.0!s}kHz is not in a known band."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if grid formatting incorrect
if spot.dx_grid and not re.match(
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper(),
):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_grid}' does not look like a valid Maidenhead grid."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if activity ref format incorrect for activity
if (
spot.activity
and spot.activity_refs
and len(spot.activity_refs) > 0
and spot.activity_refs[0].id
and get_ref_regex_for_activity(spot.activity)
and not re.match(get_ref_regex_for_activity(spot.activity), spot.activity_refs[0].id)
):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
)
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# infer missing data, and add it to our database.
spot.source = "API"
spot.infer_missing()
self._spots.set(spot.id, spot)
self.write(safe_json_dumps("OK"))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception:
logger.exception("Exception when handling client request to add spot API")
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
@@ -1,68 +0,0 @@
import logging
import tornado
from tornado.httpclient import AsyncHTTPClient
from tornado.httputil import HTTPHeaders
logger = logging.getLogger(__name__)
_LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
class V1RedirectHandler(tornado.web.RequestHandler):
"""Transparently proxies requests from the old API to the new one,
returning whatever the v2 endpoint returns, for endpoints with no breaking changes."""
async def _proxy(self, path):
new_url = f"{self.request.protocol}://{self.request.host}/api/v2/{path}"
if self.request.query:
new_url += f"?{self.request.query}"
# Copy the incoming headers so we can add translated legacy credentials without changing the original
# request.
headers = HTTPHeaders(self.request.headers)
for param, header in _LEGACY_PARAM_TO_HEADER_MAP.items():
value = self.get_query_argument(param, default=None)
if value:
headers[header] = value
client = AsyncHTTPClient()
try:
response = await client.fetch(
new_url,
method=self.request.method,
headers=headers,
body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False,
follow_redirects=False,
request_timeout=10.0,
)
except Exception as e:
logger.exception("Exception when proxying legacy v1 API request")
raise tornado.web.HTTPError(502, reason=str(e))
self.set_status(response.code, response.reason)
if isinstance(response.headers, HTTPHeaders):
for name, value in response.headers.get_all():
# Let Tornado recompute these for the outgoing response
if name.lower() not in (
"content-length",
"transfer-encoding",
"connection",
):
self.add_header(name, value)
if response.body:
self.write(response.body)
async def get(self, path):
await self._proxy(path)
async def post(self, path):
await self._proxy(path)
-57
View File
@@ -1,57 +0,0 @@
import re
from webserver.handlers.api.v2_compatibility import V2APISpotsHandler, V2APISpotsStreamHandler
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
_LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
def _handle_legacy_params(handler):
"""Copy v1 query-string QRZ/HamQTH credentials into the v2 headers, so the v2 handler can see them"""
for param, header in _LEGACY_PARAM_TO_HEADER_MAP.items():
if header in handler.request.headers:
continue
value = handler.get_query_argument(param, default=None)
if value:
handler.request.headers[header] = value
class V1APISpotsHandler(V2APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self):
_handle_legacy_params(self)
super().prepare()
def write(self, chunk):
if isinstance(chunk, str):
chunk = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', chunk)
super().write(chunk)
class V1APISpotsStreamHandler(V2APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self):
_handle_legacy_params(self)
super().prepare()
def write_message(self, *args, **kwargs):
args = list(args)
for i, a in enumerate(args):
if isinstance(a, str) and '"dx_location_source"' in a:
args[i] = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', a)
for k, v in kwargs.items():
if isinstance(v, str) and '"dx_location_source"' in v:
kwargs[k] = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', v)
super().write_message(*args, **kwargs)
-169
View File
@@ -1,169 +0,0 @@
import json
import tornado
from core.utils import safe_json_dumps
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.lookups import APILookupActivityRefHandler
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
# Query parameters renamed in v3
_V2_TO_V3_QUERY_PARAMS = {
"sig": "activity",
"needs_sig": "needs_activity",
"needs_sig_ref": "needs_activity_ref",
}
# Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"},
"fields": {"sig": "activity", "sig_refs": "activity_refs"},
}
# Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = {
"activity": "sig",
"activity_refs": "sig_refs",
"activity_type": "sig_type",
"activities": "sigs",
"activity_ref_data_providers": "sig_ref_data_providers",
"activity_name": "sig_name",
}
# Values of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_VALUES = {
"dx_location_source": {"ACTIVITY REF LOOKUP": "SIG REF LOOKUP"},
}
def _translate_v2_query_params(handler):
"""Rename any v2 query parameters (and values) in the request to their v3 equivalents, so the v3 handler can
understand them."""
for arguments in (handler.request.arguments, handler.request.query_arguments):
for v2_name, v3_name in _V2_TO_V3_QUERY_PARAMS.items():
if v2_name in arguments:
arguments[v3_name] = arguments.pop(v2_name)
for name, value_map in _V2_TO_V3_QUERY_VALUES.items():
if name in arguments:
arguments[name] = [
",".join(value_map.get(item.strip(), item) for item in v.decode("utf-8").split(",")).encode("utf-8")
for v in arguments[name]
]
def _translate_v3_response_object(obj):
"""Rename keys and values in an object from their v3 names to their v2 names."""
if isinstance(obj, dict):
translated = {}
for k, v in obj.items():
if k in _V3_TO_V2_RESPONSE_VALUES and isinstance(v, str):
v = _V3_TO_V2_RESPONSE_VALUES[k].get(v, v)
translated[_V3_TO_V2_RESPONSE_KEYS.get(k, k)] = _translate_v3_response_object(v)
return translated
if isinstance(obj, list):
return [_translate_v3_response_object(i) for i in obj]
return obj
def translate_v3_response(chunk):
"""Translate a JSON string output by a v3 handler into its v2 equivalent"""
if not isinstance(chunk, str):
return chunk
try:
return safe_json_dumps(_translate_v3_response_object(json.loads(chunk)))
except ValueError:
return chunk
def translate_v2_spot(spot_data):
"""Translate a spot provided by a client in v2 format into v3 format"""
spot_data = dict(spot_data)
if "sig" in spot_data:
spot_data["activity"] = spot_data.pop("sig")
if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list):
refs = []
for ref in spot_data["activity_refs"]:
if isinstance(ref, dict) and "sig" in ref:
ref = dict(ref)
ref["activity"] = ref.pop("sig")
refs.append(ref)
spot_data["activity_refs"] = refs
return spot_data
class _V2ResponseTranslationMixin:
"""Mixin for request handlers that translates v2 query params to v3 on the way in, and v3 JSON responses to v2 on
the way out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write(self, chunk):
super().write(translate_v3_response(chunk))
class _V2StreamTranslationMixin:
"""Mixin for SSE handlers that translates v2 query params to v3 on the way in, and v3 JSON messages to v2 on the way
out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write_message(self, name=None, msg=True, wait=None, evt_id=None):
if not name:
msg = translate_v3_response(msg)
return super().write_message(name=name, msg=msg, wait=wait, evt_id=evt_id)
class V2APISpotsHandler(_V2ResponseTranslationMixin, APISpotsHandler):
"""API request handler for /api/v2/spots (GET). Included in Spothole v3 for backwards compatibility."""
class V2APISpotsStreamHandler(_V2StreamTranslationMixin, APISpotsStreamHandler):
"""API request handler for /api/v2/spots/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsHandler(_V2ResponseTranslationMixin, APIAlertsHandler):
"""API request handler for /api/v2/alerts (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsStreamHandler(_V2StreamTranslationMixin, APIAlertsStreamHandler):
"""API request handler for /api/v2/alerts/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIOptionsHandler(_V2ResponseTranslationMixin, APIOptionsHandler):
"""API request handler for /api/v2/options (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIStatusHandler(_V2ResponseTranslationMixin, APIStatusHandler):
"""API request handler for /api/v2/status (GET). Included in Spothole v3 for backwards compatibility."""
class V2APILookupSigRefHandler(_V2ResponseTranslationMixin, APILookupActivityRefHandler):
"""API request handler for /api/v2/lookup/sigref (GET). Included in Spothole v3 for backwards compatibility. This
is the v2 equivalent of /api/v3/lookup/activityref."""
class V2APISpotHandler(APISpotHandler):
"""API request handler for /api/v2/spot (POST). Included in Spothole v3 for backwards compatibility. Translates the
spot in the request body from v2 to v3 format. The response is a plain status message so needs no translation."""
def post(self):
# Translate the request body if we can. If the body is empty or invalid JSON, leave it alone and let the v3
# handler return the appropriate error.
try:
json_body = tornado.escape.json_decode(self.request.body)
if isinstance(json_body, dict) and isinstance(json_body.get("spot"), dict):
json_body["spot"] = translate_v2_spot(json_body["spot"])
self.request.body = json.dumps(json_body).encode("utf-8")
except ValueError:
pass
super().post()
+68 -21
View File
@@ -17,6 +17,34 @@ from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.compatibility.v1_compatibility import (
V1APIAlertsHandler,
V1APIAlertsStreamHandler,
V1APIDxStatsHandler,
V1APILookupCallHandler,
V1APILookupGridHandler,
V1APILookupSigRefHandler,
V1APIOptionsHandler,
V1APISolarConditionsHandler,
V1APISpotHandler,
V1APISpotsHandler,
V1APISpotsStreamHandler,
V1APIStatusHandler,
)
from webserver.handlers.api.compatibility.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APIDxStatsHandler,
V2APILookupCallHandler,
V2APILookupGridHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISolarConditionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import (
APILookupActivityRefHandler,
@@ -27,19 +55,6 @@ from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
from webserver.handlers.api.v1_addspot import V1APISpotHandler
from webserver.handlers.api.v1_compatability import V1RedirectHandler
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
from webserver.handlers.api.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
from webserver.handlers.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
@@ -169,7 +184,7 @@ class WebServer:
),
]
# v2 API compatibility routes
# v2 API compatibility routes. Translation wrappers convert data to the v3 format and back.
v2_compat_routes = [
(
r"/api/v2/spots",
@@ -193,12 +208,12 @@ class WebServer:
),
(
r"/api/v2/solar",
APISolarConditionsHandler,
V2APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
APIDxStatsHandler,
V2APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
@@ -211,9 +226,9 @@ class WebServer:
V2APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/call", V2APILookupCallHandler),
(r"/api/v2/lookup/sigref", V2APILookupSigRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler),
(r"/api/v2/lookup/grid", V2APILookupGridHandler),
(
r"/api/v2/spot",
V2APISpotHandler,
@@ -224,27 +239,59 @@ class WebServer:
),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and are proxied to the v2 API (which in turn is
# translated from v3). The ones that have the major breaking changes get a bespoke handler.
# Translation wrappers convert data to the v3 format and back.
v1_compat_routes = [
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v1/alerts",
V1APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v1/alerts/stream",
V1APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v1/solar",
V1APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v1/dxstats",
V1APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v1/options",
V1APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v1/status",
V1APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v1/lookup/call", V1APILookupCallHandler),
(r"/api/v1/lookup/sigref", V1APILookupSigRefHandler),
(r"/api/v1/lookup/grid", V1APILookupGridHandler),
(
r"/api/v1/spot",
V1APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
(r"/api/v1/(.*)", V1RedirectHandler),
]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes