movebank-mirror-api is not "a clean re-implementation of the Movebank API."
It is a bug-to-bug-compatible replay of the live API surface as observed
by the clients people actually use — primarily the move
R package, secondarily movebank-api-client
(Java) and anything that speaks Movebank's CSV direct-read protocol.
The promise to a downstream user is: if your code works against
https://www.movebank.org/movebank, it works against http://localhost:8080/movebank
without modification. That promise is what makes the project useful — and
keeping it requires us to replicate Movebank's quirks rather than fix them.
This file documents the quirks we deliberately replicate, the reasons each exists, and what to not clean up during refactors.
Three reasons:
-
The clients depend on undocumented behaviour. The R
movepackage accesses response columns by name and position. It assumes specific column orders, accepts specific value formats (NAfor missing,true/falsefor booleans), and looks up some columns that the official API docs don't mention as guaranteed (tag_type.is_location_sensor). Cleaning up our response to match the docs would breakmove. -
The clients aren't going to change to accommodate us.
moveis on CRAN, has thousands of users, and a release cycle measured in years. Even if they accepted a patch tomorrow, the installed base would lag for half a decade. We can change the server overnight. -
The official API docs are descriptive, not normative. Movebank's actual response format is what it is. The docs lag, the implementation leads. Treating Movebank's emitted bytes as the spec — rather than the docs — is the only way to be a faithful drop-in replacement.
So: when we find a quirk, we replicate it. When something looks "weird,"
suspect it's intentional. Add a code comment and a move-r verification
test, don't refactor it away.
Every change to EventService / MirrorService / DirectReadController
must pass compatibility/move-r/verify.R before
release. That script is the regression suite for bug-compat. It currently
covers getMovebank("study"), getMovebankAnimals, getMovebankSensors,
and getMovebankData against a populated mirror.
If you find yourself thinking "this code is silly, let me clean it up," run
verify.R first. If it still passes after your cleanup, fine. If it
breaks, the silly code was load-bearing — restore and document.
Each entry describes a behaviour the live Movebank API exhibits, why we copy it, where the code lives, and (where applicable) which client demands it.
Live behaviour. The tag_type direct-read endpoint returns the columns
id, name, external_id, is_location_sensor (and possibly more) — in
that order, with these exact names.
Why it matters. move::getMovebankData does:
sensorTypes <- getMovebank("tag_type", login)
rownames(sensorTypes) <- sensorTypes$id
locSen <- sensorTypes[as.logical(sensorTypes$is_location_sensor), "id"]
...
trackDF <- merge.data.frame(trackDF, sensorTypes[, c("id", "name")],
by.x = "sensor_type_id", by.y = "id")If is_location_sensor is missing → locSen becomes empty → no events
queried. If name is missing → the merge.data.frame call throws
"undefined columns selected".
Where we encode it. MirrorService.java
— TAG_TYPES constant + TAG_TYPE_HEADER constant. Don't reorder, don't
rename, don't drop columns.
is_location_sensor is a string. Values are the literal strings
"true" / "false", not boolean JSON values, because move calls
as.logical(<that string>). R's as.logical("true") is TRUE,
as.logical("yes") is NA. Stick with "true" / "false".
Live behaviour. When the caller sends attributes=col1,col2,col3, the
response columns appear in exactly that order, with exactly those
names. Columns not in the source data appear as NA.
Why it matters. move uses colClasses indexed by name in
read.csv and downstream code does positional df[, k] access. Any
reordering breaks both.
Where we encode it. EventService.java
— streamCsvFiles's projection block + buildProjection. The output
header is built directly from requestedAttrs.toArray(new String[0])
when projection is active.
Don't "optimise" projection. A clean rewrite that streams source columns and lets the client filter would not be acceptable. The order matters; the name set matters.
Live behaviour. Movebank emits NA (the literal two characters) for
missing values in numeric columns.
Why it matters. R's read.csv defaults na.strings = "NA". Empty
strings become parse errors when colClasses declares the column numeric:
scan() expected 'a real', got ''. move always asks for
location_lat / location_long as numeric.
Where we encode it. EventService.java — streamCsvFiles projection
block emits "NA" when the source row is missing the value, and safe(...)
returns "NA" for null/empty values from deployment metadata.
Live behaviour. Movebank's CSV output quotes only values that contain the field separator, the quote character, or a newline. Plain values appear unquoted.
Why it matters. OpenCSV's default applyQuotesToAll=true quotes every
value, so NA comes out as the four characters "NA" (i.e. the cell value
literally contains the " characters after CSV unquoting). R's
na.strings = "NA" matches the unquoted literal NA, not the four-character
string "NA". With universal quoting, every numeric column whose source
is missing crashes read.csv.
Where we encode it. Every csv.writeNext(...) call in EventService
and MirrorService passes false as the applyQuotesToAll argument.
Don't drop those false arguments during cleanup.
Live behaviour. Different Movebank API surfaces use different parameter
names. The /event endpoint historically used sensor_sensor_type_id
(the relational join sensor.sensor_type_id), but the simpler
sensor_type_id is also accepted, and move::getMovebankData sends the
latter.
Where we encode it.
// EventService.writeEvents
String sensorTypeId = firstNonEmpty(
params.get("sensor_sensor_type_id"),
params.get("sensor_type_id"));Both must be accepted indefinitely. Removing either alias would break a class of clients.
Live behaviour. The /event endpoint accepts comma-separated lists for
individual_id, deployment_id, and sensor_type_id. A request with
individual_id=2911080,2911061,2911067 selects rows for any of those
individuals.
Why it matters. move::getMovebankData issues exactly this — it batches
up to 200 individual ids per request.
Where we encode it. MirrorService.csvToSet is shared across the
deployment lookup and the sensor-type list. The single-value string
equals(...) pattern was correct for the pre-move test fixtures and is
specifically wrong for real clients — that's the bug we replicate.
Live behaviour. A query string with &sensor_type_id= (empty value
after =) is treated identically to omitting the parameter. move sends
this when its locSen vector turns up empty.
Where we encode it. EventService.writeEvents runs every parameter
through nullIfEmpty(...) / firstNonEmpty(...) before use. Empty strings
are coerced to null, then the absent-parameter branch fires.
Live behaviour. When the caller requests attributes=…,tag_id, individual_id,deployment_id,event_id,…, Movebank's /event response
includes every requested column populated. event_id is the events
table's primary key. The other three are joined in from the deployment
table covering each row's timestamp, and from the tag the row belongs to.
Where the values come from in our setup. The on-disk event CSV files
written by movebank-mirror carry only event_id of these four
columns. The other three are deliberately not downloaded:
tag_idis implicit in the directory layout — every event under<study>/<tag>/...csvalready has its tag id encoded in its path.individual_idanddeployment_idare reconstructable from the study's deployment metadata: for each row's(tag, timestamp), look up the deployment whose window contains that timestamp; that row gives both ids.
Carrying any of those three in every event row would only duplicate data
that already lives in the per-study JSON, and — more importantly — would
introduce drift if a deployment is later edited in Movebank without a
full re-download of historical events. event_id is irreducible: it has
no alternative source, so the mirror requests it explicitly.
The replay server fills in the four columns at response time:
- Looks up the per-tag deployment windows for the study (one read of the per-study JSON).
- For each event row, finds the deployment window that contains the
row's
timestampon the row's tag. Emitsindividual_idanddeployment_idfrom that window's record. ("NA"if no window covers the timestamp — happens for events outside any recorded deployment.) - Uses the tag directory name as
tag_id. - For
event_id: prefers the value the CSV carries (mirrors made withmovebank-mirror≥ 0.0.2 carry it). Falls back to a monotonic per-response counter for older mirrors that never asked for it. The counter is not a stable cross-session identifier; if you need stable ids, re-mirror with the current downloader.
Order of preference for event_id. When the CSV file carries the
column, that value wins. The synthesised counter is the fallback. See
EventService.streamCsvFiles's projection branch.
Don't drop the synthesis. Even after movebank-mirror ≥ 0.0.2
propagates, old mirrors will exist in the field for years. Keep the
synthesis path.
Don't add tag_id / individual_id / deployment_id to the
mirror's downloaded columns "for symmetry." The metadata-driven
reconstruction is the canonical path; making the on-disk CSV the source
of truth for those would create the drift hazard above.
Live behaviour (us). We synthesise event_id from a counter scoped to
a single response. The same row in two consecutive requests gets two
different ids.
Why it matters / why this is OK. move uses event_id only for
duplicate-detection within a single result set, not as a stable
cross-session identifier. The "per-response monotonic counter" semantics
satisfy that. If a downstream client relies on event_id being stable
across requests, that's a request to layer a stable id on top of file
position — a separate enhancement, not a refactor of the synthesis.
Where we encode it. EventService.EventIdSeq. The class is
intentionally a per-call instance, not a service-scoped singleton.
Live behaviour. Movebank's API treats the literal string
attributes=all as a sentinel meaning "every column from the source
data, no projection." This is distinct from a column literally named
all — there is no such column.
Why it matters. move2::movebank_download_study issues
attributes=all by default for full study downloads. A naive
implementation would split the value on commas, get ["all"], and
project the response to a one-column literal-all filter — yielding
empty rows.
Where we encode it. EventService.parseAttributes returns null
(meaning "no projection") when the value is "all" (case-insensitive).
MirrorService.parseAttributes does the same. The null then routes
through the union-of-source-columns path that EventService.writeEvents
synthesises automatically.
Live behaviour. When the /deployment endpoint is queried without
an explicit attributes=…, Movebank's response omits the foreign-key
columns tag_id and individual_id. They're returned only when the
caller asks for them by name (e.g.
attributes=id,tag_id,individual_id).
You can see the workaround for this baked into our own mirror: see
MovebankMirror.getAllDeploymentRefDataForStudy
issues two requests per study (one full, one with explicit
attributes=id,individual_id,tag_id) and merges them. The on-disk
mirror data therefore does carry these columns, but emitting them by
default breaks downstream clients designed against the live API's
shape.
Why it matters. move2::movebank_download_deployment does:
deployment_data <- movebank_retrieve("deployment", study_id, rename_columns=TRUE)
deployment_table <- movebank_retrieve("deployment", study_id, attributes=c("id","tag_id","individual_id"), rename_columns=TRUE)
left_join(deployment_table, deployment_data, "deployment_id") # collision check ↓
left_join(..., individual_data, "individual_id", suffix=c("","_individual"))If deployment_data includes individual_id (and tag_id), the first
join produces individual_id.x / individual_id.y (default
.x/.y suffix). The second join then can't find a column named just
individual_id and dies with "Join columns in x must be present in
the data." Against the live API, deployment_data doesn't include
individual_id to begin with, so there's no collision.
Where we encode it. MirrorService.writeDeployments:
when no attributes is requested, the response is filtered to drop the
columns listed in DEPLOYMENT_LINKAGE_COLUMNS (currently tag_id,
individual_id). The primary key column id is left in place — the
live API does return that — and after move2's rename pass it becomes
deployment_id, which is the join key.
Don't extend the drop list to id. id becomes deployment_id
after rename, and dropping it would break the join from the other side
("Join columns in y must be present in the data.").
Don't make this conditional on the user-agent or other request
features. The behavior is "no attributes= ⇒ omit foreign keys."
That's the live API's rule. Replicate it unconditionally.
Live behaviour. Movebank's tag-type catalogue is a small, fixed table.
Clients treat it as a stable lookup. Verified 2026-05-07 by curl against
the live API: 5 columns (alphabetical) — description, external_id,
id, is_location_sensor, name — and 9 rows.
Where we encode it. MirrorService.TAG_TYPES + TAG_TYPE_HEADER.
The 9 entries match live's IDs exactly. Notable: argos-doppler-shift
is 82798 (not 397 — 397 is bird-ring). Don't trust earlier
numeric ids elsewhere; the live /tag_type is the source of truth.
Don't make it dynamic. A "scan the mirror to discover sensor types"
implementation would behave differently when the mirror is empty (returns
no rows → move errors with "no records found"). Keep the catalogue
static, even if it occasionally lags Movebank — refresh the table by
re-running the curl check above when needed.
Don't reorder the columns. Live returns columns alphabetically;
clients (e.g. older move) sometimes index by position. Stay
alphabetical.
These are gaps where our server diverges from live, but no current client breaks because of it. Listed for visibility; close them if a downstream need surfaces.
Live: ?entity_type=event&...&limit=1 returns
HTTP 500 — limit not supported. Our server returns the full result with
no limit applied. We're more permissive than the spec, but no client
relies on the rejection (yet). If we ever add real limit support,
match live by accepting only the documented values.
Live response columns for attributes=all (verified curl):
individual_id, deployment_id, tag_id, study_id, sensor_type_id,
individual_local_identifier, tag_local_identifier,
individual_taxon_canonical_name,
<eobs_*, ground_speed, heading, height_above_ellipsoid,
location_lat, location_long, timestamp, visible>,
event_id
The live API joins server-side against the individuals/tags/study tables
to embed the human-readable identifiers (*_local_identifier,
*_taxon_canonical_name) and the study_id. Our response carries
tag_id/individual_id/deployment_id/event_id/sensor_type_id but
not the four derived columns. move and move2 happen not to need
them; other tooling might. Closing this would require adding the joins
in EventService (cheap given the deployment lookup is already done).
Live order: linkage columns first (individual_id, deployment_id, tag_id,
study_id, sensor_type_id), then derived identifiers, then
sensor measurements alphabetical, then event_id. Our order is
union-of-source-headers. Both R clients access by name and don't notice;
position-indexing clients could break.
The catalogue above is for intentional quirks. The following are real
TODOs that should eventually be cleaned up — they're tracked in
TODO.md:
sortparameter is ignored.limitparameter is ignored.- No authentication. Don't expose without a reverse proxy.
These aren't load-bearing for move, so closing them won't break
compatibility. Closing them just makes us more useful to clients that
do use sort / limit (e.g. ad-hoc curl scripts).
When a new client surfaces a compatibility issue, the workflow is:
- Reproduce with
compatibility/move-r/verify.R(or the equivalent for the other client). Add the failing call toverify.R. - Trace what URL the client constructed and what columns / values it
expects. The
VERIFY_TRACE=1switch inverify.Rprints every URL. - Inspect the client source — the official docs lie (politely). For R,
selectMethod("getX", ...)thendeparse(body(...))is the standard move. - Replicate the quirk, not the cleaned-up shape. Add the source reference (file + line) as a code comment.
- Catalogue here, in a new section. Future maintainers should be able to read this file and understand why something that looks weird is actually correct.
- Re-run
verify.Rand confirm all checks pass.