POSTing a spot should use the request body not a URL param. #35

This commit is contained in:
Ian Renton
2025-10-09 15:29:25 +01:00
parent 1843286f92
commit c66693fc99
3 changed files with 45 additions and 19 deletions

View File

@@ -1,7 +1,5 @@
# ![Spothole](/webassets/img/logo.png) # ![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. 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) ![Screenshot](/images/screenshot.png)

View File

@@ -5,7 +5,7 @@ from threading import Thread
import bottle import bottle
import pytz 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.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS 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.", return json.dumps("Error - this server does not allow new spots to be added via the API.",
default=serialize_everything) default=serialize_everything)
# Reject if no spot # Reject if format not json
if not bottle.request.query.spot: 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.content_type = 'application/json'
response.status = 422 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 # Read in the request body as JSON then convert to a Spot object
json_spot = json.loads(bottle.request.query.spot) json_spot = json.loads(post_data)
spot = Spot(**json_spot) spot = Spot(**json_spot)
# Reject if no timestamp or dx_call # Reject if no timestamp or dx_call
@@ -96,6 +103,7 @@ class WebServer:
spot.icon = "desktop" spot.icon = "desktop"
spot.infer_missing() spot.infer_missing()
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
print(spot)
response.content_type = 'application/json' response.content_type = 'application/json'
response.set_header('Cache-Control', 'no-store') response.set_header('Cache-Control', 'no-store')
@@ -103,7 +111,7 @@ class WebServer:
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
response.content_type = 'application/json' response.content_type = 'application/json'
response.status = 422 response.status = 500
return json.dumps("Error - " + str(e), default=serialize_everything) return json.dumps("Error - " + str(e), default=serialize_everything)
# Serve a templated page # 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, # 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. # and thus a client can use this data to configure its filter controls.
def get_options(self): def get_options(self):
return {"bands": BANDS, options = {"bands": BANDS,
"modes": ALL_MODES, "modes": ALL_MODES,
"mode_types": MODE_TYPES, "mode_types": MODE_TYPES,
"sigs": SIGS, "sigs": SIGS,
@@ -231,3 +239,9 @@ class WebServer:
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))), map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))),
"continents": CONTINENTS, "continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE} "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

View File

@@ -263,7 +263,7 @@ paths:
/status: /status:
get: get:
tags: tags:
- status - general
summary: Get server status summary: Get server status
description: Query information about the server for use in a diagnostics display. description: Query information about the server for use in a diagnostics display.
operationId: status operationId: status
@@ -340,7 +340,7 @@ paths:
/options: /options:
get: get:
tags: tags:
- spots - general
summary: Get enumeration options 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. 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 operationId: options
@@ -398,14 +398,14 @@ paths:
tags: tags:
- spots - spots
summary: Add a spot 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 operationId: spot
parameters: requestBody:
- name: spot description: The JSON spot object
description: The spot data to post. The structure must contain at least "time" and "dx_call" to be accepted.
required: true required: true
content:
application/json:
schema: schema:
type:
$ref: '#/components/schemas/Spot' $ref: '#/components/schemas/Spot'
responses: responses:
'200': '200':
@@ -415,6 +415,13 @@ paths:
schema: schema:
type: string type: string
example: "OK" example: "OK"
'415':
description: Incorrect Content-Type
content:
application/json:
schema:
type: string
example: "Failed"
'422': '422':
description: Validation error description: Validation error
content: content:
@@ -422,6 +429,13 @@ paths:
schema: schema:
type: string type: string
example: "Failed" example: "Failed"
'500':
description: Internal server error
content:
application/json:
schema:
type: string
example: "Failed"
components: components: