Skip to content

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.

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:

  • /health and /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.

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" (see geometrikks/domain/geo/schemas.py for the idiom); they live in each domain package’s schemas.py/dtos.py or next to their controller. Digit-adjacent names pin their wire form explicitly with msgspec.field(name=...): status2xx, requestCount24h.
  • Query parameters carry explicit camelCase name= declarations (fromTimestamp, startDate, ipAddressNotIn, …); shared aliases live in geometrikks/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.key deliberately carries Python settings field names like server_latitude, and /api/v1/logs/tail records 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.

Errors use Litestar’s native HTTP-exception envelope, unchanged:

{"status_code": 404, "detail": "Not Found"}
  • extra appears 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 in geometrikks/server/exceptions.py into 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.

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.

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.

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.

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.