From 81166dccab4909474497c3764f0a41c8ca77be20 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 24 Sep 2026 21:47:09 +0100 Subject: [PATCH] Come up with a common base class for backwards-compatibility translation wrappers between the different API versions #143 --- webserver/handlers/api/addspot.py | 1 + .../api/compatibility/compatibility.py | 105 +++++++++++ .../api/compatibility/v1_compatibility.py | 113 ++++++++++++ .../api/compatibility/v2_compatibility.py | 132 ++++++++++++++ webserver/handlers/api/v1_addspot.py | 144 --------------- webserver/handlers/api/v1_compatability.py | 68 ------- webserver/handlers/api/v1_spots.py | 57 ------ webserver/handlers/api/v2_compatibility.py | 169 ------------------ webserver/webserver.py | 89 ++++++--- 9 files changed, 419 insertions(+), 459 deletions(-) create mode 100644 webserver/handlers/api/compatibility/compatibility.py create mode 100644 webserver/handlers/api/compatibility/v1_compatibility.py create mode 100644 webserver/handlers/api/compatibility/v2_compatibility.py delete mode 100644 webserver/handlers/api/v1_addspot.py delete mode 100644 webserver/handlers/api/v1_compatability.py delete mode 100644 webserver/handlers/api/v1_spots.py delete mode 100644 webserver/handlers/api/v2_compatibility.py diff --git a/webserver/handlers/api/addspot.py b/webserver/handlers/api/addspot.py index bb4b632..9d91ae6 100644 --- a/webserver/handlers/api/addspot.py +++ b/webserver/handlers/api/addspot.py @@ -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) diff --git a/webserver/handlers/api/compatibility/compatibility.py b/webserver/handlers/api/compatibility/compatibility.py new file mode 100644 index 0000000..4a8fff7 --- /dev/null +++ b/webserver/handlers/api/compatibility/compatibility.py @@ -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") diff --git a/webserver/handlers/api/compatibility/v1_compatibility.py b/webserver/handlers/api/compatibility/v1_compatibility.py new file mode 100644 index 0000000..486a162 --- /dev/null +++ b/webserver/handlers/api/compatibility/v1_compatibility.py @@ -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() diff --git a/webserver/handlers/api/compatibility/v2_compatibility.py b/webserver/handlers/api/compatibility/v2_compatibility.py new file mode 100644 index 0000000..9c61fbf --- /dev/null +++ b/webserver/handlers/api/compatibility/v2_compatibility.py @@ -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 diff --git a/webserver/handlers/api/v1_addspot.py b/webserver/handlers/api/v1_addspot.py deleted file mode 100644 index 463b4a5..0000000 --- a/webserver/handlers/api/v1_addspot.py +++ /dev/null @@ -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") diff --git a/webserver/handlers/api/v1_compatability.py b/webserver/handlers/api/v1_compatability.py deleted file mode 100644 index c855830..0000000 --- a/webserver/handlers/api/v1_compatability.py +++ /dev/null @@ -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) diff --git a/webserver/handlers/api/v1_spots.py b/webserver/handlers/api/v1_spots.py deleted file mode 100644 index dd717c8..0000000 --- a/webserver/handlers/api/v1_spots.py +++ /dev/null @@ -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) diff --git a/webserver/handlers/api/v2_compatibility.py b/webserver/handlers/api/v2_compatibility.py deleted file mode 100644 index cd17cbe..0000000 --- a/webserver/handlers/api/v2_compatibility.py +++ /dev/null @@ -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() diff --git a/webserver/webserver.py b/webserver/webserver.py index b633371..ca867c1 100644 --- a/webserver/webserver.py +++ b/webserver/webserver.py @@ -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