API conventions
The wire-format policy for the GeoMetrikks HTTP API, decided for 0.7.0.
tests/test_error_contract.py and the generated OpenAPI document
(resources/generated/openapi.json) pin these rules; change them only with a
coordinated client regeneration and a changelog migration note.
Versioning
Section titled “Versioning”Every REST endpoint lives under /api/v1, mounted as a single Litestar
Router in geometrikks/server/routes.py, which stays the one explicit
registration point. Controllers live in their vertical domain packages
(geometrikks/domain/<domain>/controllers*) and own only their domain
segment (/analytics, /crowdsec, …); the router supplies the version
prefix. Everything under /api/v1 requires the session cookie except
/api/v1/auth/login, /api/v1/auth/options, /api/v1/auth/oidc/start and
/api/v1/auth/oidc/callback (with APP_AUTH_DISABLED=true nothing requires a
session, and the auth endpoints stay registered as mode-appropriate no-ops:
/api/v1/auth/me and a valid /api/v1/auth/login return
{"mode": "disabled"}). /api/v1/auth/logout always answers 200 with
{"redirectTo": string | null}: redirectTo carries the identity
provider’s end session URL for an OIDC session with OIDC_LOGOUT_IDP=true,
and is null in every other case, including auth-disabled mode. Outside the
router:
/healthand/health/ready: unauthenticated probe endpoints./ws/live,/ws/logs,/ws/crowdsec: WebSocket feeds (geometrikks/domain/realtime/), session-authenticated during the handshake./schema(deliberately unauthenticated),/sw.js, and the SPA shell.
Field casing: camelCase
Section titled “Field casing: camelCase”All request and response JSON fields are camelCase on the wire; Python attributes stay snake_case.
- SQLAlchemy-model responses use Advanced Alchemy DTOs with
rename_strategy="camel". - Bespoke request and response models are msgspec Structs declared with
rename="camel"(seegeometrikks/domain/geo/schemas.pyfor the idiom); they live in each domain package’sschemas.py/dtos.pyor next to their controller. Digit-adjacent names pin their wire form explicitly withmsgspec.field(name=...):status2xx,requestCount24h. - Query parameters carry explicit camelCase
name=declarations (fromTimestamp,startDate,ipAddressNotIn, …); shared aliases live ingeometrikks/lib/parameters.py. - Path parameters are URL segments, not fields, and stay snake_case
(
{location_id},{job_id}). - WebSocket frame payloads are a separate contract and are not covered by this policy (revisited with the realtime refactor).
- Data values are exempt:
SettingFieldView.keydeliberately carries Python settings field names likeserver_latitude, and/api/v1/logs/tailrecords pass raw structlog context keys through as recorded (request_id,status_code, …); renaming historical log data would misrepresent it. OpenAPI documents only the stable record fields.
Error envelope: Litestar native
Section titled “Error envelope: Litestar native”Errors use Litestar’s native HTTP-exception envelope, unchanged:
{"status_code": 404, "detail": "Not Found"}extraappears additionally on request-validation errors with the per-field breakdown.- The envelope keys are the framework’s and stay snake_case; the camelCase policy applies to success payloads only.
- Domain exceptions (
DomainValidationError, CrowdSec errors) are translated centrally ingeometrikks/server/exceptions.pyinto the same envelope. - API-path 404s render this envelope too; non-API 404s (static-asset misses)
keep litestar-vite’s empty-body behavior. The 404 handler treats the whole
/api/namespace as API surface, matching the auth boundary: an unknown or unversioned/api/path is an API consumer’s mistake and gets JSON.
Operation IDs
Section titled “Operation IDs”OpenAPI operation IDs use Litestar’s default path-derived naming
(ApiV1AnalyticsSummaryGetSummary). Generated TypeScript client method names
hang off them, so route moves must keep full paths stable or accept a
client-wide rename.
CrowdSec map data
Section titled “CrowdSec map data”GET /api/v1/crowdsec/banned-locations returns a GeoJSON FeatureCollection.
This replaces its earlier array response. fromTimestamp is inclusive and
toTimestamp exclusive; omitted bounds keep the 30-day default. Repeat
countryCode, city, and serverIn to apply the same filters as the map.
Each Point feature groups identical coordinates in longitude, latitude order.
Its id matches properties.groupId; properties.ipCount is the number of
distinct addresses in the complete properties.bannedIps list. Each entry
includes ip, locationId, city, countryCode, and eventCount, the
IP’s request total in the filtered window; the list runs from the highest
eventCount down, ties by address. Collection stats totals the
mapped IPs, coordinate groups, events, distinct countries, and distinct
cities. Decision details come from /api/v1/crowdsec/decisions/lookup?ip=....
The response covers IPs under any current IP-scoped decision, of every origin
and remediation type, that have matching retained traffic; this is the same
membership as /banned-ips. It does not describe expired decisions, prove
that requests were blocked, or expand range, country, or AS decisions. Each IP has
one representative observed location; inside an aggregate bucket the busiest
location wins. The response skips IPs without coordinates, and retained
history bounds how far back it can see.
CrowdSec badge data
Section titled “CrowdSec badge data”GET /api/v1/crowdsec/banned-ips returns one { "ip", "type" } object per
IP under an active IP-scoped decision, every origin included. Addresses are
in canonical form, so two spellings of one IPv6 address collapse into one
entry. This replaces its earlier array of bare addresses. type is the decision the UI badges
the IP with: ban when any of the IP’s decisions is a ban, else captcha
when any is a captcha, else the first other name in sorted order.
/ws/crowdsec decision frames carry type on every added and deleted
entry, with addresses in the same canonical form. A deleted entry does not say whether the IP still holds another
decision; clients refetch banned-ips after a delta to settle that.
Abuse reports
Section titled “Abuse reports”GET /api/v1/reports/abuse takes startDate, endDate and exactly one of
ipAddress or asn. Anything else answers 400. For an ASN it returns the
busiest maxIps addresses by request count, 25 by default and 100 at most.
ipCount, totalRequests and status4xx still cover every address of the
ASN in the window. linesPerIp sets how many of each address’s newest log
lines come back, oldest first. It defaults to 20 and allows up to 500, and
0 leaves the excerpt out. All timestamps are UTC.
The response goes to a third party, so it never carries host,
server, referrer or remote_user. The operator’s names and address
become [redacted] in paths and user agents, and redactions counts the
replacements. The scrubbing follows the rules below and can miss a name or
secret it has no rule for, so the operator has to read a report before
sending it.
host holds whatever Host header the client sent, so a scanner can put
any name there. A host counts as the operator’s when it got a 2xx response,
or when at least 5 distinct clients used it, in the 30 days up to the
report end. A redirect does not count, because an HTTP to HTTPS redirect
answers any Host. A scanner’s spoofed Host: www.google.com passes neither
test and stays in the evidence. Once a host counts, its registrable domain
counts too, along with the chosen IPs’ hosts under that domain. Public
addresses count; private ones do not. Server names count when they
contain a dot.
The match covers other spellings of an operator address, such as
195.000.194.210 or an IPv6 address written out in full, and names
percent-encoded in a path. A query value is replaced whole when it names
the operator, so a forward-auth redirect like
?rd=https://app.example.com/api?apikey=... loses its key along with the
host. So is any value whose parameter name ends in a credential word, such
as apikey, X-Plex-Token, password or session, including inside a
relative redirect.
CrowdSec decisions and alerts from any origin other than crowdsec,
CAPI, lists and appsec were made by a person. They report the
scenario manual instead of that person’s reason or machine name. crowdsec.status is one
of:
disabled: no CrowdSec integration.ok: decisions and alerts are included.decisions-only: no machine credentials, so no alerts.unavailable: the LAPI failed. The access-log evidence is still there.
Alerts are fetched for the report window only, at most the newest 1000 for
a single IP and the newest 100 for each IP of an ASN. alertsTruncated
says when an IP hit the limit. The LAPI has no batch lookup, so an ASN
report makes two requests per address, one after the other, for four
addresses at a time.
GET /api/v1/reports/abuse-contact takes ipAddress or asn and returns
the RDAP record’s holder, prefixes and abuse addresses. Only the query
leaves the server, sent to IANA’s bootstrap files and the registry they
name. Every request and redirect must be https to a public DNS name.
Private and reserved addresses answer 400. Space that no registry covers or
holds answers 404, and a failed upstream request answers 502. Answers are
cached for a day.