mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-25 08:44:33 +00:00
sig->activity and multiple activity changes for API v3. #143
This commit is contained in:
@@ -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,157 @@
|
||||
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": "activities", "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 V2SpotsAlertsCompatibilityWrapper(V2CompatibilityWrapper):
|
||||
"""Extra translation for spots and alerts. In v3 these have a list of "activities" rather than a single activity,
|
||||
so for v2 we collapse this back down to a single value using the first activity in the list. This must happen
|
||||
before the generic key renaming, which would otherwise rename "activities" to "sigs" as it does for /options."""
|
||||
|
||||
def translate_response_object(self, obj):
|
||||
return super().translate_response_object(collapse_activities(obj))
|
||||
|
||||
|
||||
class V2APISpotsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APISpotsHandler):
|
||||
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
|
||||
|
||||
|
||||
class V2APISpotsStreamHandler(V2SpotsAlertsCompatibilityWrapper, StreamCompatibilityWrapper, APISpotsStreamHandler):
|
||||
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
|
||||
|
||||
|
||||
class V2APIAlertsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APIAlertsHandler):
|
||||
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
|
||||
|
||||
|
||||
class V2APIAlertsStreamHandler(V2SpotsAlertsCompatibilityWrapper, 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:
|
||||
sig = spot_data.pop("sig")
|
||||
spot_data["activities"] = [sig] if sig else []
|
||||
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
|
||||
|
||||
|
||||
def collapse_activities(obj):
|
||||
"""Utility method to replace the "activities" list in a spot or alert JSON object with a single "activity" value,
|
||||
being the first activity in the list, or None if there are none. The object can be a single spot/alert dict, or a
|
||||
list of them. Anything else is returned untouched. Used to translate v3's list of activities to the single sig
|
||||
expected in v2 API calls."""
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [collapse_activities(i) for i in obj]
|
||||
if isinstance(obj, dict) and "activities" in obj:
|
||||
obj = dict(obj)
|
||||
activities = obj.pop("activities")
|
||||
obj["activity"] = activities[0] if activities else None
|
||||
return obj
|
||||
Reference in New Issue
Block a user