sig->activity and multiple activity changes for API v3. #143

This commit is contained in:
ian
2026-09-25 07:01:13 +01:00
committed by Ian Renton
parent 02d08c17cd
commit ecdcbe17e9
92 changed files with 1113 additions and 796 deletions
+22 -25
View File
@@ -21,7 +21,7 @@ RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)"""
"""API request handler for /api/v3/spot (POST)"""
def __init__(
self,
@@ -142,24 +142,19 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Reject if activity ref format incorrect for activity
if (
spot.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."
# Reject if any activity ref format is incorrect for its activity
for activity_ref in spot.activity_refs:
ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None
if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{activity_ref.id}' does not look like a valid reference for {activity_ref.activity}."
)
)
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
@@ -171,13 +166,14 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.sig:
if not spot.activities:
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
self.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.sig_refs and upstream_provider_name != "Tiles":
if not spot.activity_refs and upstream_provider_name != "Tiles":
self.set_status(422)
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
self.set_header("Cache-Control", "no-store")
@@ -201,7 +197,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.sig)
provider = self._find_provider(upstream_provider_name, spot.activities)
if provider:
try:
# Submit spot to the upstream provider
@@ -216,12 +212,13 @@ class APISpotHandler(tornado.web.RequestHandler):
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
)
else:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.sig if spot.sig else ''} spots."
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# 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)
@@ -241,11 +238,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activity) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity."""
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for at least one of the given activities."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(activity):
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
return p
return None
+10 -10
View File
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts"""
"""API request handler for /api/v3/alerts"""
def __init__(
self,
@@ -69,7 +69,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/alerts/stream"""
"""API request handler for /api/v3/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None
@@ -169,13 +169,13 @@ def alert_allowed_by_query(alert, query):
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check.
if (
alert.sig == ActivityName.DXPEDITION
ActivityName.DXPEDITION in alert.activities
and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
):
continue
if (
alert.sig == ActivityName.CONTEST
ActivityName.CONTEST in alert.activities
and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
):
@@ -186,14 +186,14 @@ def alert_allowed_by_query(alert, query):
sources = query.get(k).split(",")
if not alert.source or alert.source not in sources:
return False
case "sig":
# If a list of activities is provided, the alert must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches alerts with no activity.
case "activity":
# If a list of activities is provided, the alert must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not alert.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not alert.activities and not include_no_activity:
return False
if alert.sig and alert.sig not in activities:
if alert.activities and not any(a in activities for a in alert.activities):
return False
case "dx_continent":
dxconts = query.get(k).split(",")
@@ -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
+1 -1
View File
@@ -20,7 +20,7 @@ HF_BANDS = [b.name for b in BANDS if b.is_ham_hf]
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats"""
"""API request handler for /api/v3/dxstats"""
def __init__(
self,
+9 -9
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call"""
"""API request handler for /api/v3/lookup/call"""
def __init__(
self,
@@ -63,7 +63,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupActivityRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/sigref"""
"""API request handler for /api/v3/lookup/activityref"""
def __init__(
self,
@@ -79,16 +79,16 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# "sig" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# "activity" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# that activity, the provided id must match it.
if "sig" in query_params and "id" in query_params:
activity = str(query_params.get("sig")).upper()
if "activity" in query_params and "id" in query_params:
activity = str(query_params.get("activity")).upper()
ref_id = str(query_params.get("id")).upper()
if get_activity_by_name(activity):
if not get_ref_regex_for_activity(activity) or re.match(
get_ref_regex_for_activity(activity), ref_id
):
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, sig=activity))
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, activity=activity))
self.write(safe_json_dumps(data))
else:
@@ -99,10 +99,10 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
)
self.set_status(422)
else:
self.write(safe_json_dumps(f"Error - sig '{activity}' is not known."))
self.write(safe_json_dumps(f"Error - activity '{activity}' is not known."))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - sig and id must be provided"))
self.write(safe_json_dumps("Error - activity and id must be provided"))
self.set_status(422)
except Exception:
@@ -115,7 +115,7 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/grid"""
"""API request handler for /api/v3/lookup/grid"""
def __init__(
self,
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options"""
"""API request handler for /api/v3/options"""
def __init__(
self,
@@ -75,7 +75,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"bands": BANDS,
"modes": [m.value for m in Mode],
"mode_types": [t.value for t in ModeType],
"sigs": list(ACTIVITIES.values()),
"activities": list(ACTIVITIES.values()),
"spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers,
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar"""
"""API request handler for /api/v3/solar"""
def __init__(
self,
+13 -13
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots"""
"""API request handler for /api/v3/spots"""
def __init__(
self,
@@ -68,7 +68,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/spots/stream"""
"""API request handler for /api/v3/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None
@@ -196,25 +196,25 @@ def spot_allowed_by_query(spot, query):
sources = query.get(k).split(",")
if not spot.source or spot.source not in sources:
return False
case "sig":
# If a list of activities is provided, the spot must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches spots with no activity.
case "activity":
# If a list of activities is provided, the spot must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not spot.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not spot.activities and not include_no_activity:
return False
if spot.sig and spot.sig not in activities:
if spot.activities and not any(a in activities for a in spot.activities):
return False
case "needs_sig":
case "needs_activity":
# If true, an activity is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
# exclusive with supplying the special "NO_ACTIVITY" parameter to the "activity" query param.
needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.sig:
if needs_activity and not spot.activities:
return False
case "needs_sig_ref":
case "needs_activity_ref":
# If true, at least one activity ref is required, regardless of what it is, it just can't be missing.
needs_activity_ref = query.get(k).upper() == "TRUE"
if needs_activity_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
if needs_activity_ref and (not spot.activity_refs or len(spot.activity_refs) == 0):
return False
case "band":
bands = query.get(k).split(",")
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status"""
"""API request handler for /api/v3/status"""
def __init__(
self,
-141
View File
@@ -1,141 +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
logger = logging.getLogger(__name__)
class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in early Spothole v2 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
json_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.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."
)
)
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)
-55
View File
@@ -1,55 +0,0 @@
import re
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
_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(APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in early Spothole v2 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(APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in early Spothole v2 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)
+131 -19
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,9 +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.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
@@ -107,50 +132,50 @@ class WebServer:
# API endpoints are always enabled
api_routes = [
(
r"/api/v2/spots",
r"/api/v3/spots",
APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
r"/api/v3/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
r"/api/v3/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
r"/api/v3/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
r"/api/v3/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
r"/api/v3/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
r"/api/v3/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
r"/api/v3/status",
APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupActivityRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler),
(r"/api/v3/lookup/call", APILookupCallHandler),
(r"/api/v3/lookup/activityref", APILookupActivityRefHandler),
(r"/api/v3/lookup/grid", APILookupGridHandler),
(
r"/api/v2/spot",
r"/api/v3/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
@@ -159,27 +184,114 @@ class WebServer:
),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
# that have the major breaking changes get a bespoke handler.
# v2 API compatibility routes. Translation wrappers convert data to the v3 format and back.
v2_compat_routes = [
(
r"/api/v2/spots",
V2APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
V2APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
V2APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
V2APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
V2APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
V2APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
V2APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
V2APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", V2APILookupCallHandler),
(r"/api/v2/lookup/sigref", V2APILookupSigRefHandler),
(r"/api/v2/lookup/grid", V2APILookupGridHandler),
(
r"/api/v2/spot",
V2APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
]
# 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
@@ -253,7 +365,7 @@ class WebServer:
]
app = tornado.web.Application(
api_routes + v1_compat_routes + ui_routes + misc_routes,
api_routes + v2_compat_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False,