From be625c40a4b526806f9d8acfc4c62cf342475714 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 10 Sep 2026 07:54:34 +0100 Subject: [PATCH] Add "fields" query parameter to spots and alerts API calls. Closes #140 --- server/handlers/api/alerts.py | 19 +++++++++++++++++++ server/handlers/api/spots.py | 18 ++++++++++++++++++ static/apidocs/openapi.yml | 25 +++++++++++++++++++++++++ templates/add_spot.html | 2 +- templates/alerts.html | 2 +- templates/bands.html | 4 ++-- templates/base.html | 10 +++++----- templates/conditions.html | 2 +- templates/map.html | 4 ++-- templates/spots.html | 4 ++-- templates/status.html | 2 +- 11 files changed, 77 insertions(+), 15 deletions(-) diff --git a/server/handlers/api/alerts.py b/server/handlers/api/alerts.py index 868d7ae..8e785c2 100644 --- a/server/handlers/api/alerts.py +++ b/server/handlers/api/alerts.py @@ -58,8 +58,12 @@ class APIAlertsHandler(tornado.web.RequestHandler): # Fetch all alerts matching the query, then optionally enrich with online data credentials = extract_credentials(self.request.headers) data = get_alert_list_with_filters(self._alerts, query_params) + fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else [] if credentials: data = self._enrich(data, credentials) + # Filter for only the required fields, if necessary + if fields: + data = filter_fields(data, fields) self.write(safe_json_dumps(data)) self.set_status(200) except ValueError as e: @@ -81,6 +85,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): self._web_server_metrics = None self._query_params = None self._credentials = None + self._fields = None super().__init__(application, request, **kwargs) def initialize(self, sse_alert_broadcaster, web_server_metrics): @@ -104,6 +109,9 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): # reduce that to just the first entry, and convert bytes to string self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._credentials = extract_credentials(self.request.headers) + self._fields = ( + [f.strip() for f in self._query_params["fields"].split(",")] if "fields" in self._query_params else [] + ) # Flush headers immediately so nginx doesn't time out waiting for a response self.write_message("keepalive", "") @@ -126,10 +134,15 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): """Callback when a new alert arrives""" try: + # If the new alert matches our param filters, send it to the client. If not, ignore it. if alert_allowed_by_query(alert, self._query_params): + # Add lookup data if we have credentials if self._credentials: alert = copy.deepcopy(alert) alert.infer_missing(self._credentials) + # Filter fields returned if necessary + if self._fields: + alert = filter_fields([alert], self._fields)[0] self.write_message(msg=safe_json_dumps(alert)) except Exception: logger.exception("Exception in SSE callback, connection will be closed") @@ -216,3 +229,9 @@ def alert_allowed_by_query(alert, query): ): return False return True + + +def filter_fields(alerts, fields): + """Given a list of alert objects, return copies containing only the named fields.""" + + return [{k: v for k, v in alert.__dict__.items() if k in fields} for alert in alerts] diff --git a/server/handlers/api/spots.py b/server/handlers/api/spots.py index 5dfb180..3f9a7c2 100644 --- a/server/handlers/api/spots.py +++ b/server/handlers/api/spots.py @@ -56,9 +56,13 @@ class APISpotsHandler(tornado.web.RequestHandler): # Fetch all spots matching the query, then optionally enrich with online data credentials = extract_credentials(self.request.headers) + fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else [] data = get_spot_list_with_filters(self._spots, query_params) if credentials: data = self._enrich(data, credentials) + # Filter for only the required fields, if necessary + if fields: + data = filter_fields(data, fields) self.write(safe_json_dumps(data)) self.set_status(200) except ValueError as e: @@ -80,6 +84,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): self._web_server_metrics = None self._query_params = None self._credentials = None + self._fields = None super().__init__(application, request, **kwargs) def initialize(self, sse_spot_broadcaster, web_server_metrics): @@ -105,6 +110,9 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): # reduce that to just the first entry, and convert bytes to string self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._credentials = extract_credentials(self.request.headers) + self._fields = ( + [f.strip() for f in self._query_params["fields"].split(",")] if "fields" in self._query_params else [] + ) # Flush headers immediately so nginx doesn't time out waiting for a response self.write_message("keepalive", "") @@ -129,9 +137,13 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): try: # If the new spot matches our param filters, send it to the client. If not, ignore it. if spot_allowed_by_query(spot, self._query_params): + # Add lookup data if we have credentials if self._credentials: spot = copy.deepcopy(spot) spot.infer_missing(self._credentials) + # Filter fields returned if necessary + if self._fields: + spot = filter_fields([spot], self._fields)[0] self.write_message(msg=safe_json_dumps(spot)) except Exception: logger.exception("Exception in SSE callback, connection will be closed") @@ -266,3 +278,9 @@ def spot_allowed_by_query(spot, query): if needs_good_location and not spot.dx_location_good: return False return True + + +def filter_fields(spots, fields): + """Given a list of spot objects, return copies containing only the named fields.""" + + return [{k: v for k, v in spot.__dict__.items() if k in fields} for spot in spots] diff --git a/static/apidocs/openapi.yml b/static/apidocs/openapi.yml index 0c834b1..8eda0ab 100644 --- a/static/apidocs/openapi.yml +++ b/static/apidocs/openapi.yml @@ -24,6 +24,7 @@ info: * Added `alert_type` and `url` to alert data * Added `contests_skip_max_duration_check` to alert query parameters * SIG reference types (e.g. "Park") are now capitalised to match other enums + * Added the ability to get only certain fields of spots and alerts from the API by using the `fields` query parameter. ### 2.0 @@ -140,6 +141,7 @@ paths: - $ref: '#/components/parameters/SpotTextIncludes' - $ref: '#/components/parameters/SpotNeedsGoodLocation' - $ref: '#/components/parameters/SpotAllowQrt' + - $ref: '#/components/parameters/SpotFields' - $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzSessionKey' @@ -180,6 +182,7 @@ paths: - $ref: '#/components/parameters/SpotTextIncludes' - $ref: '#/components/parameters/SpotNeedsGoodLocation' - $ref: '#/components/parameters/SpotAllowQrt' + - $ref: '#/components/parameters/SpotFields' - $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzSessionKey' @@ -217,6 +220,7 @@ paths: - $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxCallIncludes' - $ref: '#/components/parameters/AlertTextIncludes' + - $ref: '#/components/parameters/AlertFields' - $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzSessionKey' @@ -252,6 +256,7 @@ paths: - $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxCallIncludes' - $ref: '#/components/parameters/AlertTextIncludes' + - $ref: '#/components/parameters/AlertFields' - $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzSessionKey' @@ -657,6 +662,16 @@ components: schema: type: boolean default: true + SpotFields: + name: fields + in: query + description: > + Filter the fields you receive in each spot, to conserve data bandwidth for constrained applications. + Supply a comma-separated list of the fields you want to receive, using the names of the fields returned by the + `/spots` call, e.g. `id,dx_call,freq,mode,time`. If the "fields" parameter is not supplied, all fields will be + included in the spot data. + schema: + type: string AlertMaxDuration: name: max_duration in: query @@ -795,6 +810,16 @@ components: you will get all the more recent alerts back, without duplicating the previous latest spot. schema: type: number + AlertFields: + name: fields + in: query + description: > + Filter the fields you receive in each alert, to conserve data bandwidth for constrained applications. + Supply a comma-separated list of the fields you want to receive, using the names of the fields returned by the + `/alerts` call, e.g. `id,dx_calls,freqs_modes,start_time`. If the "fields" parameter is not supplied, all fields + will be included in the alert data. + schema: + type: string CallParam: name: call in: query diff --git a/templates/add_spot.html b/templates/add_spot.html index 1ce7717..f00f363 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -77,7 +77,7 @@ - + diff --git a/templates/alerts.html b/templates/alerts.html index 1def641..bb56ac1 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -83,7 +83,7 @@ - + diff --git a/templates/bands.html b/templates/bands.html index 567a03a..2a7bf4c 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,8 +76,8 @@ - - + + diff --git a/templates/base.html b/templates/base.html index cd3db52..0f15f3c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -16,10 +16,10 @@ window.fetchEventSource = fetchEventSource; - - - - + + + + {% end %} {% block body %}
diff --git a/templates/conditions.html b/templates/conditions.html index 3339b71..58e6330 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -284,7 +284,7 @@
- + diff --git a/templates/map.html b/templates/map.html index 1dc7e41..f7de6ee 100644 --- a/templates/map.html +++ b/templates/map.html @@ -113,8 +113,8 @@ const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; - - + + diff --git a/templates/spots.html b/templates/spots.html index 5fc9d19..0fcfe5e 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -113,8 +113,8 @@ - - + + diff --git a/templates/status.html b/templates/status.html index 54557dd..438bd26 100644 --- a/templates/status.html +++ b/templates/status.html @@ -86,7 +86,7 @@ - +