From c66693fc9947cdbce2d6bffce78e78665a16e9dd Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 9 Oct 2025 15:29:25 +0100 Subject: [PATCH] POSTing a spot should use the request body not a URL param. #35 --- README.md | 2 -- server/webserver.py | 30 ++++++++++++++++++++++-------- webassets/apidocs/openapi.yml | 32 +++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8a15e0a..3e12abc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # ![Spothole](/webassets/img/logo.png) -**Work in progress.** - Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data. ![Screenshot](/images/screenshot.png) diff --git a/server/webserver.py b/server/webserver.py index 596fd81..c022819 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -5,7 +5,7 @@ from threading import Thread import bottle import pytz -from bottle import run, response, template +from bottle import run, request, response, template from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS @@ -74,14 +74,21 @@ class WebServer: return json.dumps("Error - this server does not allow new spots to be added via the API.", default=serialize_everything) - # Reject if no spot - if not bottle.request.query.spot: + # Reject if format not json + if not request.get_header('Content-Type') or request.get_header('Content-Type') != "application/json": + response.content_type = 'application/json' + response.status = 415 + return json.dumps("Error - request Content-Type must be application/json", default=serialize_everything) + + # Reject if request body is empty + post_data = request.body.read() + if not post_data: response.content_type = 'application/json' response.status = 422 - return json.dumps("Error - no 'spot' parameter provided", default=serialize_everything) + return json.dumps("Error - request body is empty", default=serialize_everything) - # Read in the spot as JSON then convert to a Spot object - json_spot = json.loads(bottle.request.query.spot) + # Read in the request body as JSON then convert to a Spot object + json_spot = json.loads(post_data) spot = Spot(**json_spot) # Reject if no timestamp or dx_call @@ -96,6 +103,7 @@ class WebServer: spot.icon = "desktop" spot.infer_missing() self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE) + print(spot) response.content_type = 'application/json' response.set_header('Cache-Control', 'no-store') @@ -103,7 +111,7 @@ class WebServer: except Exception as e: logging.error(e) response.content_type = 'application/json' - response.status = 422 + response.status = 500 return json.dumps("Error - " + str(e), default=serialize_everything) # Serve a templated page @@ -220,7 +228,7 @@ class WebServer: # The idea is that this will include most of the things that can be provided as queries to the main spots call, # and thus a client can use this data to configure its filter controls. def get_options(self): - return {"bands": BANDS, + options = {"bands": BANDS, "modes": ALL_MODES, "mode_types": MODE_TYPES, "sigs": SIGS, @@ -231,3 +239,9 @@ class WebServer: map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))), "continents": CONTINENTS, "max_spot_age": MAX_SPOT_AGE} + # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from + # one of our proviers. + if ALLOW_SPOTTING: + options["spot_sources"].append("API") + + return options diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 5f53c12..99d396c 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -263,7 +263,7 @@ paths: /status: get: tags: - - status + - general summary: Get server status description: Query information about the server for use in a diagnostics display. operationId: status @@ -340,7 +340,7 @@ paths: /options: get: tags: - - spots + - general summary: Get enumeration options description: Retrieves the list of options for various enumerated types, which can be found in the spots and also provided back to the API as query parameters. While these enumerated options are defined in this spec anyway, providing them in an API call allows us to define extra parameters, like the colours associated with bands, and also allows clients to set up their filters and features without having to have internal knowledge about, for example, what bands the server knows about. operationId: options @@ -398,14 +398,14 @@ paths: tags: - spots summary: Add a spot - description: Supply a new spot object, which will be added to the system. Currently, this will not be reported up the chain to a cluster, POTA, SOTA etc. This will be introduced in a future version. + description: "Supply a new spot object, which will be added to the system. Currently, this will not be reported up the chain to a cluster, POTA, SOTA etc. This will be introduced in a future version. cURL example: `curl --request POST --header \"Content-Type: application/json\" --data '{\"dx_call\":\"M0TRT\",\"time\":1760019539, \"freq\":14200000, \"comment\":\"Test spot please ignore\", \"de_call\":\"M0TRT\"}' https://spothole.app/api/spot`" operationId: spot - parameters: - - name: spot - description: The spot data to post. The structure must contain at least "time" and "dx_call" to be accepted. - required: true - schema: - type: + requestBody: + description: The JSON spot object + required: true + content: + application/json: + schema: $ref: '#/components/schemas/Spot' responses: '200': @@ -415,6 +415,13 @@ paths: schema: type: string example: "OK" + '415': + description: Incorrect Content-Type + content: + application/json: + schema: + type: string + example: "Failed" '422': description: Validation error content: @@ -422,6 +429,13 @@ paths: schema: type: string example: "Failed" + '500': + description: Internal server error + content: + application/json: + schema: + type: string + example: "Failed" components: