Skip to content

Commit 0a43d4b

Browse files
naglepuffclaude
andauthored
Add local mock for NABat's GraphQL API and file-download pipeline (#541)
* Add local mock for NABat's GraphQL API and file-download pipeline BatAI's NABat integration could only be exercised locally as far as the Keycloak login flow - actual recording fetches still hit NABat's real GraphQL API and S3 storage, neither of which is reachable from a local dev setup. Since BatAI never uses a real GraphQL client (every query is a plain templated string with no schema or variables), a minimal stand-in service can dispatch on substrings in the query text instead of running an actual GraphQL server. Renames dev/keycloak to dev/nabat_mock and docker-compose-keycloak.yml to docker-compose-nabat-mock.yml, and adds: - mock_server.py: mocks the single-recording fetch query, the annotation-push mutation, and the presigned-URL access check, generating real presigned URLs against the existing minio service so the download and spectrogram-generation pipeline runs end to end. Also seeds one already-vetted annotation per newly-fetched recording, so the annotations list isn't empty on first load. - upload_recording.py: uploads a .wav file (defaulting to the repo's own assets/example.wav) into minio for those presigned URLs to resolve to. - Env wiring for both full-Docker and native (host-run Django/celery) development, since the two need different minio hostnames baked into the signed URLs. - A Keycloak realm fix: the "sub" claim was missing from access tokens under this realm's lightweight-token configuration, which broke annotation creation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Remove unused regex * Clarify dev-only use of NABAT_API_URL env var * Remove nabat-mock only variable from dev .env file --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 3848af5 commit 0a43d4b

11 files changed

Lines changed: 437 additions & 29 deletions

‎dev/.env.docker-compose‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,10 @@ DJANGO_BATAI_NABAT_OIDC_CLIENT_SECRET=batai-local-dev-secret
2020
DJANGO_BATAI_NABAT_OIDC_ISSUER=http://localhost:8081/auth/realms/NABAT
2121
DJANGO_BATAI_NABAT_OIDC_BASE_URL=http://keycloak:8080/auth/realms/NABAT
2222

23+
# Only reachable if docker-compose-nabat-mock.yml's nabat-mock service is also up; otherwise
24+
# inert, the same way the OIDC vars above are unused unless Keycloak is also up.
25+
# In production environments, ensure that this environment variable is unset. The base settings
26+
# module handles the real NABat graphql endpoint.
27+
DJANGO_BATAI_NABAT_API_URL=http://nabat-mock:8082/graphql
28+
2329
VITE_API_ROOT=http://localhost:8000

‎dev/.env.docker-compose-native‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,10 @@ DJANGO_BATAI_NABAT_OIDC_CLIENT_SECRET=batai-local-dev-secret
1111
DJANGO_BATAI_NABAT_OIDC_ISSUER=http://localhost:8081/auth/realms/NABAT
1212
DJANGO_BATAI_NABAT_OIDC_BASE_URL=http://localhost:8081/auth/realms/NABAT
1313

14+
# Only reachable if docker-compose-nabat-mock.yml's nabat-mock service is also up; otherwise
15+
# inert, the same way the OIDC vars above are unused unless Keycloak is also up.
16+
# In production environments, ensure that this environment variable is unset. The base settings
17+
# module handles the real NABat graphql endpoint.
18+
DJANGO_BATAI_NABAT_API_URL=http://localhost:8082/graphql
19+
1420
VITE_API_ROOT=http://localhost:8000

‎dev/keycloak/README.md‎

Lines changed: 0 additions & 25 deletions
This file was deleted.

‎dev/nabat_mock/Dockerfile‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM python:3-slim
2+
3+
RUN pip install --no-cache-dir minio
4+
5+
COPY mock_server.py /app/mock_server.py
6+
WORKDIR /app
7+
8+
CMD ["python", "mock_server.py"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
"access.token.claim": "true",
2121
"claim.name": "preferred_username"
2222
}
23+
},
24+
{
25+
"name": "sub",
26+
"protocol": "openid-connect",
27+
"protocolMapper": "oidc-sub-mapper",
28+
"consentRequired": false,
29+
"config": {
30+
"id.token.claim": "true",
31+
"access.token.claim": "true"
32+
}
2333
}
2434
]
2535
},

‎dev/nabat_mock/README.md‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Mocking the NABat Integration
2+
3+
The integration with the NABat platform requires some trickier auth and API calls to test
4+
fully. This directory contains helpful tools for local development testing, covering both
5+
halves of the integration:
6+
7+
- **Keycloak** stands in for NABat's OIDC login, so the "open in batai" redirect and code
8+
exchange can be exercised for real.
9+
- **nabat-mock** stands in for NABat's GraphQL API (`BATAI_NABAT_API_URL`), so recording
10+
fetches can run end to end against real infrastructure (the existing `minio` service)
11+
instead of hitting `sciencebase.gov`.
12+
13+
## Running the mock stack
14+
15+
In the top-level directory of the repository there is a docker compose file that can be used
16+
in conjunction with whichever docker compose files you already use for development. This means
17+
you only need to spin up these services when required. To chain docker compose files, simply
18+
use the `-f` flag multiple times. For example, with local development:
19+
20+
```bash
21+
docker compose -f docker-compose.yml -f docker-compose-nabat-mock.yml up
22+
```
23+
24+
## Keycloak Configuration
25+
26+
Keycloak configuration is defined in [NABAT-realm.json](./NABAT-realm.json). It sets up 2
27+
clients: a proxy "NABat" and a client for the locally running BatAI application. It also sets
28+
up a test user (this would be the analog to your account with NABat). The username and
29+
password for this user are both `testuser`.
30+
31+
This realm issues lightweight access tokens, so any claim batai needs has to be re-added by
32+
an explicit protocol mapper - including `sub` (the user's Keycloak ID), which Keycloak
33+
otherwise strips from lightweight tokens by default even though it's normally unconditional
34+
per the OIDC spec. `create_recording_annotation` reads `sub` directly, so the `profile` scope
35+
carries an `oidc-sub-mapper` for it. Keep that in mind if this realm export ever gets
36+
regenerated from Keycloak's admin UI - it's easy to lose.
37+
38+
## nabat-mock
39+
40+
[mock_server.py](./mock_server.py) stands in for NABat's GraphQL API. BatAI never uses a real
41+
GraphQL client - every query is a plain string with literal IDs, POSTed as `{"query": "..."}`
42+
- so rather than running an actual GraphQL server, the mock just pattern-matches on substrings
43+
in the query text and returns canned JSON shaped like NABat's real responses.
44+
45+
It's currently scoped to the single-recording fetch flow only (not NABat's "file list"
46+
feature). Any `recording_id` resolves successfully except `0`, which is reserved to simulate
47+
"not found / access denied" and exercise the existing 403 handling.
48+
49+
Every recording fetched this way also gets one already-vetted species seeded onto it, so
50+
`create_nabat_recording_from_response()` picks it up and creates a matching
51+
`NABatRecordingAnnotation` automatically on first fetch - there's something to see in the
52+
annotations list immediately rather than an empty one. The seeded annotation's email matches
53+
the Keycloak test user (`testuser@example.com`), so it's visible when browsing as them.
54+
Pushing an annotation back to NABat (`.../push-to-nabat`) is mocked too and always succeeds.
55+
56+
This seeding requires a local `Species` row with a matching pk to already exist - species
57+
sync itself isn't mocked. Defaults to pk `1`; override `SEED_ANNOTATION_SPECIES_ID` /
58+
`SEED_ANNOTATION_EMAIL` if your database doesn't have that row or you want a different one.
59+
Most dev databases already have Species data from prior real NABat usage, before this mock
60+
existed.
61+
62+
Presigned URLs are generated for real against the `minio` service already in
63+
`docker-compose.yml`, so the download and spectrogram-generation steps run against real
64+
infrastructure. nabat-mock itself never connects to minio - presigning is pure local
65+
signing - it only needs to know the *bucket name*, not to reach it. Until an object
66+
actually exists at `recordings/example.wav`, the presigned URL it returns will 404 when
67+
downloaded.
68+
69+
Seed that object (and create the bucket) with [upload_recording.py](./upload_recording.py),
70+
once minio is up:
71+
72+
```bash
73+
docker compose -f docker-compose.yml -f docker-compose-nabat-mock.yml up -d minio
74+
uv run dev/nabat_mock/upload_recording.py
75+
```
76+
77+
With no path given, it uploads the repo's own [assets/example.wav](../../assets/example.wav).
78+
Pass a different path to use your own file instead - it must actually be a `.wav` (checked
79+
both by extension and by parsing it with Python's `wave` module). The project's own
80+
virtualenv already has the `minio` package (a transitive dependency via
81+
django-minio-storage), so `uv run` needs no extra install.
82+
83+
### Running celery natively (not in Docker)
84+
85+
Whoever downloads the presigned URL (the celery worker) must be able to resolve the host
86+
baked into it. nabat-mock signs against `MINIO_ENDPOINT`, defaulting to the compose-network
87+
name `minio:9000` - fine if celery is also containerized. If you run celery natively (see
88+
[native-development.md](../native-development.md)), it can't resolve `minio`; only the
89+
published port on `localhost` is reachable from the host.
90+
91+
If you run celery locally for your development, make sure to set `MINIO_ENDPOINT=localhost:9000` in your environment.
92+
93+
```bash
94+
MINIO_ENDPOINT=localhost:9000 docker compose -f docker-compose.yml -f docker-compose-nabat-mock.yml up
95+
```
96+
97+
## Testing the full flow
98+
99+
Once the mock stack is running alongside BatAI, run [./print-nabat-auth-url.sh](./print-nabat-auth-url.sh)
100+
to generate a URL.
101+
102+
Paste the URL into your browser, and if this is the first time you're going through the
103+
workflow or KC doesn't have an active token for `testuser` you'll need to log in as
104+
`testuser`. This redirects to your local BatAI application, exchanges the code with
105+
Keycloak, and then kicks off a real fetch of the recording (backed by nabat-mock and minio)
106+
and real spectrogram generation - the same pipeline production runs, driven entirely by
107+
local infrastructure.

‎dev/nabat_mock/mock_server.py‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Minimal stand-in for NABat's GraphQL API, for local dev/testing.
3+
4+
BatAI never uses a real GraphQL client: every query is a plain string,
5+
interpolated with literal IDs and POSTed as {"query": "..."}, with no
6+
variables and no schema. So instead of running a GraphQL server, this
7+
dispatches on substrings in the query text and returns canned JSON shaped
8+
like NABat's real responses.
9+
10+
Scope (for now): only the single-recording fetch flow, i.e. the query shapes used by:
11+
- bats_ai/core/tasks/nabat/nabat_data_retrieval.py (fetchAcousticAndSurveyEventInfo)
12+
- bats_ai/core/views/nabat/nabat_recording.py (bare presignedUrlFromAcousticFile,
13+
used as an access check by get_email_if_authorized / generate_nabat_recording;
14+
and the updateAcousticFileVet mutation, used to push an annotation to NABat)
15+
Anything else (species sync, NABat file lists) returns a GraphQL-shaped error
16+
rather than crashing, so it's obvious a handler needs to be added rather than
17+
failing confusingly downstream.
18+
19+
The combined query's response seeds one already-vetted species for every recording
20+
fetched (see SEED_ANNOTATION_*), so create_nabat_recording_from_response() picks it
21+
up and creates a matching NABatRecordingAnnotation automatically on first fetch -
22+
giving you something to see immediately rather than an empty annotation list. This
23+
requires a local Species row with a matching pk to already exist (the species-sync
24+
query isn't mocked); most dev databases already have one from prior real usage.
25+
26+
Presigned URLs are generated for real against the `minio` service already in
27+
docker-compose.yml, so the full download + spectrogram-generation pipeline
28+
runs end-to-end against real infrastructure. Run upload_recording.py to seed
29+
the object they point at (it also creates the bucket - this service never
30+
needs a live connection to minio at all, since presigning is pure local
31+
signing and does no network I/O).
32+
33+
Whoever downloads a presigned URL (the celery worker) needs to be able to
34+
resolve the host baked into it, and that host must match what the URL was
35+
signed with. Set MINIO_ENDPOINT accordingly: the compose-network name
36+
`minio:9000` if celery also runs in Docker (the default), or `localhost:9000`
37+
if celery runs natively on the host (see dev/.env.docker-compose-native),
38+
since only the published port is reachable from there.
39+
"""
40+
41+
from __future__ import annotations
42+
43+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
44+
import json
45+
import logging
46+
import os
47+
import re
48+
49+
from minio import Minio
50+
51+
logging.basicConfig(level=logging.INFO)
52+
logger = logging.getLogger("nabat-mock")
53+
54+
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
55+
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioAccessKey")
56+
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioSecretKey")
57+
MINIO_BUCKET = os.environ.get("MINIO_BUCKET", "nabat-mock")
58+
PORT = int(os.environ.get("PORT", "8082"))
59+
60+
# The single shared object every mocked recording_id resolves to. Per-ID fixture
61+
# files aren't needed: any recording_id "just works" against this one object.
62+
RECORDING_OBJECT_KEY = "recordings/example.wav"
63+
64+
# Reserved recording_id that always resolves to "not found", to exercise the
65+
# existing 403 handling in get_email_if_authorized / generate_nabat_recording.
66+
NOT_FOUND_RECORDING_ID = 0
67+
68+
# The "already vetted in NABat" species every newly-fetched recording seeds an
69+
# annotation for. Must be a pk that actually exists in the local Species table
70+
# (see the module docstring). Email matches dev/nabat_mock/NABAT-realm.json's test
71+
# user, so the seeded annotation is visible when browsing as them.
72+
SEED_ANNOTATION_SPECIES_ID = int(os.environ.get("SEED_ANNOTATION_SPECIES_ID", "1"))
73+
SEED_ANNOTATION_EMAIL = os.environ.get("SEED_ANNOTATION_EMAIL", "testuser@example.com")
74+
75+
ACOUSTIC_FILE_ID_RE = re.compile(r'acousticFileId:\s*"?(\d+)"?')
76+
77+
# Presigning is pure local signing - no network I/O - as long as a region is given
78+
# (otherwise minio-py falls back to a live GetBucketLocation request). So this never
79+
# actually needs to connect to minio; it just needs MINIO_ENDPOINT to be whatever host
80+
# the downloader (celery) can resolve. See the module docstring.
81+
minio_client = Minio(
82+
MINIO_ENDPOINT,
83+
access_key=MINIO_ACCESS_KEY,
84+
secret_key=MINIO_SECRET_KEY,
85+
secure=False,
86+
region="us-east-1",
87+
)
88+
89+
90+
def extract_id(pattern: re.Pattern, query: str) -> int | None:
91+
match = pattern.search(query)
92+
return int(match.group(1)) if match else None
93+
94+
95+
def presigned_recording_url(recording_id: int) -> str | None:
96+
if recording_id == NOT_FOUND_RECORDING_ID:
97+
return None
98+
return minio_client.presigned_get_object(MINIO_BUCKET, RECORDING_OBJECT_KEY)
99+
100+
101+
def build_presigned_only_response(query: str) -> dict:
102+
"""Mirrors nabat_recording.py's QUERY: presignedUrlFromAcousticFile only."""
103+
recording_id = extract_id(ACOUSTIC_FILE_ID_RE, query)
104+
url = presigned_recording_url(recording_id) if recording_id is not None else None
105+
return {"data": {"presignedUrlFromAcousticFile": {"s3PresignedUrl": url} if url else None}}
106+
107+
108+
def build_combined_response(query: str) -> dict:
109+
"""Mirrors nabat_data_retrieval.py's fetchAcousticAndSurveyEventInfo query."""
110+
recording_id = extract_id(ACOUSTIC_FILE_ID_RE, query)
111+
url = presigned_recording_url(recording_id) if recording_id is not None else None
112+
113+
return {
114+
"data": {
115+
"presignedUrlFromAcousticFile": {"s3PresignedUrl": url} if url else None,
116+
"surveyEventById": {
117+
"createdBy": "mock@nabat.org",
118+
"createdDate": "2024-01-01T00:00:00",
119+
"eventGeometryByEventGeometryId": {
120+
"description": "Mock survey event geometry",
121+
"geom": {"geojson": None},
122+
},
123+
"acousticBatchesBySurveyEventId": {
124+
"nodes": [
125+
{
126+
"id": "mock-batch",
127+
"acousticFileBatchesByBatchId": {
128+
"nodes": [
129+
{
130+
"autoId": SEED_ANNOTATION_SPECIES_ID,
131+
"manualId": SEED_ANNOTATION_SPECIES_ID,
132+
"vetter": SEED_ANNOTATION_EMAIL,
133+
"speciesByManualId": None,
134+
}
135+
]
136+
},
137+
}
138+
]
139+
},
140+
},
141+
"acousticFileById": {
142+
"fileName": f"mock_recording_{recording_id}.wav",
143+
"recordingTime": "2024-01-01T00:00:00",
144+
"s3Verified": True,
145+
"sizeBytes": 0,
146+
},
147+
}
148+
}
149+
150+
151+
def build_update_vet_response(query: str) -> dict:
152+
"""Mirrors nabat_recording.py's UPDATE_QUERY (updateAcousticFileVet mutation).
153+
154+
Always succeeds. update_nabat_species only checks for an "errors" key, never
155+
reads the payload, so no fields here need to reflect what was actually sent.
156+
"""
157+
return {"data": {"updateAcousticFileVet": {"acousticFileBatchId": 1}}}
158+
159+
160+
def build_response(query: str) -> dict:
161+
if "updateAcousticFileVet" in query:
162+
return build_update_vet_response(query)
163+
if "fetchAcousticAndSurveyEventInfo" in query:
164+
return build_combined_response(query)
165+
if "presignedUrlFromAcousticFile" in query:
166+
return build_presigned_only_response(query)
167+
return {"errors": [{"message": "nabat-mock has no handler for this query yet"}]}
168+
169+
170+
class Handler(BaseHTTPRequestHandler):
171+
def log_message(self, format_, *args):
172+
logger.info("%s - %s", self.address_string(), format_ % args)
173+
174+
def do_POST(self):
175+
length = int(self.headers.get("Content-Length", 0))
176+
body = self.rfile.read(length)
177+
try:
178+
payload = json.loads(body)
179+
query = payload.get("query", "")
180+
except json.JSONDecodeError:
181+
query = ""
182+
183+
response_body = json.dumps(build_response(query)).encode("utf-8")
184+
185+
self.send_response(200)
186+
self.send_header("Content-Type", "application/json")
187+
self.send_header("Content-Length", str(len(response_body)))
188+
self.end_headers()
189+
self.wfile.write(response_body)
190+
191+
192+
def main():
193+
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) # noqa: S104 (container-internal)
194+
logger.info("nabat-mock listening on :%s", PORT)
195+
server.serve_forever()
196+
197+
198+
if __name__ == "__main__":
199+
main()
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
# Prints a Keycloak authorization URL you can paste into a browser to kick off the
33
# NABat -> batai redirect flow for real, the same way clicking "open in batai" in the
44
# NABat portal does (see scripts/USGS/sampleUrl.txt for the legacy apiToken-URL equivalent).
5-
# Since batai doesn't handle the `code` param yet, the landing page will just show today's
6-
# apiToken-less route - this is for exercising the Keycloak leg of the flow in a real browser.
5+
# With docker-compose-nabat-mock.yml's nabat-mock service also up, this drives the full
6+
# pipeline end to end: Keycloak login, code exchange, and then a real fetch + spectrogram
7+
# generation against the mocked NABat GraphQL API and the existing minio service.
78
set -euo pipefail
89

910
KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8081/auth}

0 commit comments

Comments
 (0)