mangrovedigital/tide-engine-api
0
1"""Tides API — the connection layer between the engine and your UI.2 3Zero dependencies (Python stdlib only). Run:4 python api_server.py # serves http://localhost:87875Then hit http://localhost:8787/ in a browser to see every endpoint.6 7All levels are FEET, datum NAVD88. All times are UTC (epoch seconds + ISO);8each spot carries a `tz` so the UI formats local time. Every response has a9`status` field: "ok" | "hold:<reason>" | "error:<reason>" — render HOLD as10"unavailable", never a fake number.11 12FLORIDA BAY (2026-07-01): spots/points with no representative live gauge ride a13NOAA predicted-tide anchor instead — response carries units.datum = "MLLW14(station datum)", a `wind_correction` HOLD note, and `limitations`. Render the15flag; never imply a wind-corrected number there.16 17ENDPOINTS (the contract your UI calls):18 GET /api/spots19 GET /api/forecast?spot=east-river&hours=7220 GET /api/now?spot=east-river21 GET /api/alerts?spot=east-river&threshold_ft=0.322 GET /api/navigability?spot=east-river&lat=..&lon=..&draft_ft=2.023"""24import os, json, time, math, threading, logging25from collections import OrderedDict26from urllib.parse import urlparse, parse_qs27from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer28 29from engine import spots as spots_mod30from engine.forecast import TideForecaster31from engine import navigability as nav_mod32from engine import point_forecast33from engine import sources34from engine import wind_sentinel35import waypoint_export36 37# ---- observability (L1): structured request logging + error counter ----38logging.basicConfig(level=os.environ.get("TIDES_LOG_LEVEL", "INFO"), format="%(message)s")39_log = logging.getLogger("tides")40_metrics = {"requests": 0, "errors": 0}41 42PORT = int(os.environ.get("PORT", 8790)) # hosts (Render/Fly/Railway) set $PORT43M2FT = 3.2808444 45# ---- production config (H1/H6): env-tunable, safe-by-default ----46API_KEY = os.environ.get("TIDES_API_KEY") # if set -> required47RATE_LIMIT = int(os.environ.get("TIDES_RATE_LIMIT", "60")) # requests / window / caller48RATE_WINDOW = float(os.environ.get("TIDES_RATE_WINDOW", "60"))49CORS_ORIGINS = [o.strip() for o in os.environ.get("TIDES_CORS_ORIGINS", "").split(",") if o.strip()]50MAX_BODY = int(os.environ.get("TIDES_MAX_BODY", str(256 * 1024))) # 256 KB POST cap51CACHE_MAX = int(os.environ.get("TIDES_CACHE_MAX", "256"))52CACHE_TTL = 60053REGION_LAT = (23.0, 30.0) # SW FL / Everglades / Keys / Tampa / Big Bend envelope54REGION_LON = (-84.0, -79.0)55 56# ---- bounded LRU payload cache (H1) ----57_cache = OrderedDict() # key -> (ts, payload)58_cache_lock = threading.Lock()59 60 61def cached(key, fn):62 with _cache_lock:63 hit = _cache.get(key)64 if hit and time.time() - hit[0] < CACHE_TTL:65 _cache.move_to_end(key)66 return hit[1]67 val = fn() # compute outside the lock68 with _cache_lock:69 _cache[key] = (time.time(), val)70 _cache.move_to_end(key)71 while len(_cache) > CACHE_MAX:72 _cache.popitem(last=False)73 return val74 75 76# ---- token-bucket rate limit per caller key/IP (H1) ----77_buckets = {} # caller -> (tokens, last_ts)78_buckets_lock = threading.Lock()79 80 81def rate_ok(caller):82 now = time.time()83 with _buckets_lock:84 tokens, last = _buckets.get(caller, (float(RATE_LIMIT), now))85 tokens = min(RATE_LIMIT, tokens + (now - last) * (RATE_LIMIT / RATE_WINDOW))86 if tokens < 1.0:87 _buckets[caller] = (tokens, now)88 return False89 _buckets[caller] = (tokens - 1.0, now)90 if len(_buckets) > 4096: # opportunistic cleanup (unbounded-dict guard)91 stale = [k for k, (_, l) in _buckets.items() if now - l > RATE_WINDOW * 4]92 for k in stale[:1024]:93 _buckets.pop(k, None)94 return True95 96 97def region_ok(lat, lon):98 return (math.isfinite(lat) and math.isfinite(lon)99 and REGION_LAT[0] <= lat <= REGION_LAT[1]100 and REGION_LON[0] <= lon <= REGION_LON[1])101 102 103# ---- derived data the UI wants ----104def extremes(series, key, win=3):105 """Local highs/lows of `key` -> [{epoch,t,type,level_ft}]."""106 pts = [p for p in series if p.get(key) is not None]107 out = []108 for i in range(win, len(pts) - win):109 v = pts[i][key]110 seg = [pts[j][key] for j in range(i - win, i + win + 1)]111 if v == max(seg) and v > min(seg):112 out.append({"epoch": pts[i]["epoch"], "t": pts[i]["t"], "type": "high", "level_ft": v})113 elif v == min(seg) and v < max(seg):114 out.append({"epoch": pts[i]["epoch"], "t": pts[i]["t"], "type": "low", "level_ft": v})115 return out116 117 118def alerts(series, threshold_ft=0.3, offset_ft=0.0):119 """Sustained WIND-DRIVEN divergence from now -> dump/fill events (M6).120 121 divergence = corrected - chart - r0 (r0 = anchor_offset_ft)122 The standing anchor offset r0 is a constant seasonal/tidal-datum offset, NOT a123 wind event; including it (the old `corrected - chart`) fired perpetual alerts124 with zero wind change. Here we threshold only the wind-driven CHANGE, matching125 the product's "wind is dropping/raising water vs now" claim."""126 def dvg(p):127 return p["corrected_ft"] - p["tide_ft"] - offset_ft128 fut = [p for p in series if p["lead_h"] > 0 and p["corrected_ft"] is not None]129 evts = []; run = []130 def flush(run):131 if not run:132 return133 peak = max(run, key=lambda p: abs(dvg(p)))134 d = round(dvg(peak), 2)135 if abs(d) < threshold_ft:136 return137 kind = "dump" if d < 0 else "fill"138 evts.append({139 "type": kind,140 "window_start": run[0]["t"], "window_end": run[-1]["t"],141 "peak_epoch": peak["epoch"], "peak_t": peak["t"],142 "peak_delta_ft": d,143 "headline": "Wind %s water ~%.1f ft %s current" % (144 "dropping" if d < 0 else "raising", abs(d), "below" if d < 0 else "above"),145 })146 for p in fut:147 if abs(dvg(p)) >= threshold_ft:148 run.append(p)149 else:150 flush(run); run = []151 flush(run)152 return evts153 154 155# ---- endpoint handlers (return (status_code, dict)) ----156def h_spots(q):157 return 200, {"status": "ok",158 "spots": [spots_mod.public(s) for s in spots_mod.SPOTS.values()]}159 160 161def _forecast_payload(spot, hours):162 # route preset spots through the universal point engine (uses lat/lon, nearest163 # anchor, datum-gated passability) so presets and GPS share one code path.164 out = point_forecast.forecast_at_gps(spot["lat"], spot["lon"], horizon_h=hours)165 ok = out.get("status") == "OK"166 series = out.get("series", [])167 return {168 "status": "ok" if ok else "hold:" + str(out.get("status")),169 "spot": spots_mod.public(spot),170 "issued_utc": out.get("issued_utc"), "anchor": out.get("anchor"),171 "anchor_offset_ft": out.get("anchor_offset_ft"),172 # units come from the engine: NAVD88 on live-gauge anchors, MLLW on173 # NOAA prediction anchors (Florida Bay hybrid) — never hardcoded.174 "units": out.get("units") or {"level": "ft", "datum": "NAVD88", "time": "UTC"},175 "series": series,176 "extremes": {"chart": extremes(series, "tide_ft"),177 "corrected": extremes(series, "corrected_ft")},178 "passability": out.get("passability"),179 "provenance": out.get("provenance"),180 # honesty flags from prediction-only anchors pass through untouched181 "wind_correction": out.get("wind_correction"),182 "limitations": out.get("limitations"),183 "microtidal": out.get("microtidal", False),184 "noaa_extremes": out.get("extremes"),185 }186 187 188def h_forecast(q):189 spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])190 if not spot:191 return 404, {"status": "error:unknown spot"}192 hours = int((q.get("hours") or ["72"])[0])193 return 200, cached(("fc", spot["id"], hours), lambda: _forecast_payload(spot, hours))194 195 196def h_now(q):197 spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])198 if not spot:199 return 404, {"status": "error:unknown spot"}200 fc = cached(("fc", spot["id"], 72), lambda: _forecast_payload(spot, 72))201 now_pt = next((p for p in fc["series"] if p.get("is_now")), None)202 if fc["status"].startswith("hold") or now_pt is None:203 return 200, {"status": fc["status"] if fc["status"] != "ok" else "hold:no current point",204 "spot": spots_mod.public(spot)}205 return 200, {"status": "ok", "spot": spots_mod.public(spot),206 "t_utc": now_pt["t"],207 "tide_ft": now_pt["tide_ft"], "corrected_ft": now_pt["corrected_ft"],208 "observed_ft": now_pt["observed_ft"],209 "offset_ft": fc["anchor_offset_ft"],210 "wind": {"kt": now_pt["wind_kt"], "from_deg": now_pt["wind_dir_from"]},211 "wind_correction": fc.get("wind_correction"),212 "units": fc.get("units")}213 214 215def h_alerts(q):216 spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])217 if not spot:218 return 404, {"status": "error:unknown spot"}219 thr = float((q.get("threshold_ft") or ["0.3"])[0])220 fc = cached(("fc", spot["id"], 72), lambda: _forecast_payload(spot, 72))221 if fc["status"].startswith("hold"):222 return 200, {"status": fc["status"], "spot": spots_mod.public(spot), "alerts": []}223 return 200, {"status": "ok", "spot": spots_mod.public(spot),224 "alerts": alerts(fc["series"], thr, fc.get("anchor_offset_ft") or 0.0)}225 226 227def h_navigability(q):228 # passability via the universal engine (respects the NAVD88 datum gate -> no229 # false-confident depth at gage-height anchors). Accepts spot= or lat/lon.230 # M2: honor draft_ft (validated). Absent -> engine default (0.6 m ≈ 2 ft).231 draft_m = 0.6232 dq = q.get("draft_ft")233 if dq:234 try:235 dft = float(dq[0])236 except (TypeError, ValueError):237 return 400, {"status": "error:draft_ft must be numeric"}238 if not (math.isfinite(dft) and 0.0 < dft <= 10.0):239 return 400, {"status": "error:draft_ft must be finite and within (0, 10] ft"}240 draft_m = dft / 3.28084241 qlat = q.get("lat"); qlon = q.get("lon")242 if qlat and qlon:243 try:244 lat = float(qlat[0]); lon = float(qlon[0])245 except (TypeError, ValueError):246 return 400, {"status": "error:lat/lon must be numeric"}247 if not region_ok(lat, lon):248 return 400, {"status": "error:lat/lon must be finite and within the service region"}249 out = point_forecast.forecast_at_gps(lat, lon, draft_m=draft_m)250 p = out.get("passability", {"status": "hold:no data"})251 return 200, {"status": p.get("status", "hold"), "gps": out.get("gps"),252 "draft_ft": round(draft_m * M2FT, 2), "passability": p}253 spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])254 if not spot:255 return 404, {"status": "error:spot or lat/lon required"}256 out = point_forecast.forecast_at_gps(spot["lat"], spot["lon"], draft_m=draft_m)257 return 200, {"status": out.get("status"), "spot": spots_mod.public(spot),258 "draft_ft": round(draft_m * M2FT, 2), "passability": out.get("passability")}259 260 261def h_forecast_gps(q):262 """Point-GPS wind-corrected tide + passability at any lat/lon in the region."""263 try:264 lat = float((q.get("lat") or [None])[0]); lon = float((q.get("lon") or [None])[0])265 except (TypeError, ValueError):266 return 400, {"status": "error:lat & lon required"}267 if not region_ok(lat, lon):268 return 400, {"status": "error:lat/lon must be finite and within the service region"}269 return 200, cached(("gps", round(lat, 4), round(lon, 4)),270 lambda: point_forecast.forecast_at_gps(lat, lon))271 272 273def h_wind_sentinel(q):274 """GET /api/wind_sentinel?lat=&lon= -> trailing wind advisory for the point.275 276 Additive endpoint; does not modify forecast_gps or any existing handler.277 """278 try:279 lat = float((q.get("lat") or [None])[0])280 lon = float((q.get("lon") or [None])[0])281 except (TypeError, ValueError):282 return 400, {"status": "error:lat & lon required"}283 if not region_ok(lat, lon):284 return 400, {"status": "error:lat/lon must be finite and within the service region"}285 return 200, wind_sentinel.wind_sentinel_for_point(lat, lon)286 287 288def h_conditions(q):289 """GET /api/conditions?lat=&lon=&t= (or ?spot=) -> the full condition stamp:290 tide phase + corrected level, wind, pressure + trend, moon, solunar period,291 anchor/provenance. Powers the journal condition-stamp and the FE display."""292 from engine import finn293 tq = q.get("t")294 t = None295 if tq:296 try:297 t = float(tq[0])298 except (TypeError, ValueError):299 return 400, {"status": "error:t must be epoch seconds"}300 sp = spots_mod.SPOTS.get((q.get("spot") or [""])[0])301 if sp:302 return 200, finn.conditions(sp["lat"], sp["lon"], epoch=t,303 tz=sp.get("tz", "America/New_York"),304 spot=spots_mod.public(sp))305 qlat = q.get("lat"); qlon = q.get("lon")306 if not (qlat and qlon):307 return 404, {"status": "error:spot or lat/lon required"}308 try:309 lat = float(qlat[0]); lon = float(qlon[0])310 except (TypeError, ValueError):311 return 400, {"status": "error:lat/lon must be numeric"}312 if not region_ok(lat, lon):313 return 400, {"status": "error:lat/lon must be finite and within the service region"}314 return 200, finn.conditions(lat, lon, epoch=t)315 316 317def h_finn_spot_time(q):318 """GET /api/finn/spot_time?lat=&lon=&date=&species= -> 'best time to fish319 this spot on <date>' for a species. Always returns a doctrine-shaped call."""320 from engine import finn321 try:322 lat = float((q.get("lat") or [None])[0]); lon = float((q.get("lon") or [None])[0])323 except (TypeError, ValueError):324 return 400, {"status": "error:lat & lon required"}325 if not region_ok(lat, lon):326 return 400, {"status": "error:lat/lon must be finite and within the service region"}327 date = (q.get("date") or [None])[0]328 species = (q.get("species") or ["snook"])[0]329 return 200, finn.spot_time(lat, lon, date, species)330 331 332def h_finn_plan(body):333 """POST /api/finn/plan {lat,lon (launch),date,species[],bait[],zone,draft_ft?,334 first_name?} -> the three-shape day plan. Returns (code, dict)."""335 from engine import finn336 if not isinstance(body, dict):337 return 400, {"status": "error:body must be a JSON object"}338 try:339 lat = float(body["lat"]); lon = float(body["lon"])340 except (KeyError, TypeError, ValueError):341 return 400, {"status": "error:lat & lon (launch point) required"}342 if not region_ok(lat, lon):343 return 400, {"status": "error:lat/lon must be finite and within the service region"}344 species = body.get("species") or []345 bait = body.get("bait") or []346 if not isinstance(species, list) or not isinstance(bait, list):347 return 400, {"status": "error:species and bait must be lists"}348 zone = body.get("zone")349 if zone is not None:350 try:351 zone = {k: float(zone[k]) for k in ("west", "south", "east", "north")}352 except (KeyError, TypeError, ValueError):353 return 400, {"status": "error:zone must have numeric west/south/east/north"}354 draft_ft = body.get("draft_ft")355 if draft_ft is not None:356 try:357 draft_ft = float(draft_ft)358 except (TypeError, ValueError):359 return 400, {"status": "error:draft_ft must be numeric"}360 if not (0.0 < draft_ft <= 10.0):361 return 400, {"status": "error:draft_ft must be within (0, 10] ft"}362 first_name = body.get("first_name")363 if first_name is not None and not isinstance(first_name, str):364 return 400, {"status": "error:first_name must be a string"}365 return 200, finn.plan(lat, lon, body.get("date"), species, bait=bait, zone=zone,366 draft_ft=draft_ft, first_name=first_name)367 368 369def h_finn_chat(body):370 """POST /api/finn/chat {messages[],lat?,lon?,first_name?} -> conversational371 Finn. The LLM is the mouth; the engine tools are the only brain. Returns372 (code, dict). User content is never logged."""373 from engine import finn_chat374 if not isinstance(body, dict):375 return 400, {"status": "error:body must be a JSON object"}376 msgs = body.get("messages")377 if not isinstance(msgs, list) or not msgs:378 return 400, {"status": "error:messages[] required"}379 req = {"messages": msgs, "first_name": body.get("first_name")}380 for k in ("lat", "lon"):381 if body.get(k) is not None:382 try:383 req[k] = float(body[k])384 except (TypeError, ValueError):385 return 400, {"status": "error:%s must be numeric" % k}386 out = finn_chat.chat(req)387 code = 200 if out.get("status", "").startswith(("ok", "hold")) else 400388 return code, out389 390 391ROUTES = {"/api/spots": h_spots, "/api/forecast": h_forecast, "/api/now": h_now,392 "/api/alerts": h_alerts, "/api/navigability": h_navigability,393 "/api/forecast_gps": h_forecast_gps, "/api/wind_sentinel": h_wind_sentinel,394 "/api/conditions": h_conditions,395 "/api/finn/spot_time": h_finn_spot_time}396 397POST_ROUTES = {"/api/finn/plan": h_finn_plan, "/api/finn/chat": h_finn_chat}398 399 400def h_export(body):401 """POST /api/export -> the waypoint file itself (not JSON).402 403 body: {"format": "gpx"|"usr", "spots": [{"label","lat","lon"}, ...]}404 Returns (code, headers, bytes). STATELESS: nothing is stored; the file holds405 only the posted spots. Invalid coordinates are skipped + reported in the406 X-Export-Skipped* headers, never emitted as fake points."""407 if not isinstance(body, dict):408 return 400, {}, b'{"status": "error:body must be a JSON object"}'409 try:410 result = waypoint_export.export(body.get("format"), body.get("spots"))411 except ValueError as e:412 return 400, {}, ('{"status": "error:%s"}' % str(e)[:200]).encode()413 except waypoint_export.GPSBabelUnavailable:414 # Can't produce a real, Navico-readable .usr -> HOLD, never a fake file.415 return 503, {}, (b'{"status": "hold:gpsbabel unavailable", '416 b'"detail": "server missing GPSBabel; .usr export '417 b'temporarily unavailable"}')418 except waypoint_export.GPSBabelError as e:419 return 502, {}, ('{"status": "error:gpsbabel:%s"}' % str(e)[:200]).encode()420 headers = {421 "Content-Type": result.content_type,422 "Content-Disposition": 'attachment; filename="%s"' % result.filename,423 "X-Export-Count": str(result.count),424 }425 # Report skips/truncations without persisting anything (headers only).426 if result.skipped:427 headers["X-Export-Skipped"] = str(len(result.skipped))428 headers["X-Export-Skipped-Detail"] = json.dumps(429 result.skipped, separators=(",", ":"))[:1800]430 if result.truncated:431 headers["X-Export-Truncated"] = str(len(result.truncated))432 return 200, headers, result.data433 434INDEX = {"service": "Tides API", "units": {"level": "ft", "datum": "NAVD88", "time": "UTC"},435 "endpoints": ["GET /api/spots", "GET /api/forecast?spot=&hours=72",436 "GET /api/now?spot=", "GET /api/alerts?spot=&threshold_ft=0.3",437 "GET /api/navigability?spot=&lat=&lon=&draft_ft=2.0",438 "GET /api/forecast_gps?lat=&lon= (point-GPS tide + passability)",439 "GET /api/wind_sentinel?lat=&lon= (trailing wind advisory)",440 "GET /api/conditions?lat=&lon=&t= (or ?spot=) "441 "-> full condition stamp: tide phase+level, wind, pressure+trend, moon, solunar",442 "POST /api/finn/plan {lat,lon(launch),date,species[],bait[],zone,draft_ft?,first_name?} "443 "-> Dockside Finn's three-shape day plan",444 "GET /api/finn/spot_time?lat=&lon=&date=&species= -> best time to fish this spot",445 "POST /api/finn/chat {messages[],lat?,lon?,first_name?} "446 "-> conversational Finn (LLM voice over engine tools)",447 "POST /api/export {format:'gpx'|'usr', spots:[{label,lat,lon}]} "448 "-> waypoint file (GPX direct, .usr via GPSBabel; stateless)"],449 "status_values": ["ok", "hold:<reason>", "error:<reason>"],450 "florida_bay_note": "prediction-anchored responses carry units.datum='MLLW (station datum)' "451 "+ a wind_correction HOLD note — render the flag, never a fake corrected number"}452 453 454# TrueTide v2 front page: inline __MAPS_KEY__ placeholder; the Space injects the455# real key via the MAPS_KEY env var. Missing key -> placeholder stays, and the456# page shows its own 'map key not configured' note instead of crashing.457_HTML_PATH = os.path.join(os.path.dirname(__file__), "webapp", "truetide_v2.html")458_HTML_BODY = open(_HTML_PATH, "rb").read().replace(459 b"__MAPS_KEY__",460 os.environ.get("MAPS_KEY", "__MAPS_KEY__").encode()461)462 463 464class Handler(BaseHTTPRequestHandler):465 # ---- CORS: allowlist from env, else '*' for local dev ----466 def _cors_origin(self):467 origin = self.headers.get("Origin")468 if not CORS_ORIGINS:469 return "*" # dev default (no allowlist configured)470 if origin and origin in CORS_ORIGINS:471 return origin # echo only allowed origins472 return None # disallowed -> no CORS header473 474 def _api_key(self):475 return (self.headers.get("X-API-Key")476 or parse_qs(urlparse(self.path).query).get("key", [None])[0])477 478 def _caller(self):479 return self._api_key() or self.client_address[0]480 481 def _guard(self, path):482 """Auth + rate limit. Returns None if allowed, else (code, obj)."""483 if path in ("/health", "/", "/api", "/api/index"):484 return None # health + front page + index always open485 if API_KEY and self._api_key() != API_KEY:486 return 401, {"status": "error:unauthorized (missing/invalid API key)"}487 if not rate_ok(self._caller()):488 return 429, {"status": "error:rate limit exceeded"}489 return None490 491 def _observe(self, route):492 ms = round((time.time() - getattr(self, "_t0", time.time())) * 1000, 1)493 code = getattr(self, "_status", 0)494 _metrics["requests"] += 1495 if code >= 500:496 _metrics["errors"] += 1497 _log.info(json.dumps({"route": route, "status": code, "ms": ms,498 "upstream_fetches": sources.get_fetch_count()},499 separators=(",", ":")))500 501 def _send(self, code, obj):502 self._status = code503 body = json.dumps(obj, indent=2).encode()504 self.send_response(code)505 self.send_header("Content-Type", "application/json")506 origin = self._cors_origin()507 if origin:508 self.send_header("Access-Control-Allow-Origin", origin)509 if CORS_ORIGINS:510 self.send_header("Vary", "Origin")511 self.send_header("Content-Length", str(len(body)))512 self.end_headers()513 self.wfile.write(body)514 515 def _send_bytes(self, code, headers, body):516 self._status = code517 self.send_response(code)518 for k, v in headers.items():519 self.send_header(k, v)520 origin = self._cors_origin()521 if origin:522 self.send_header("Access-Control-Allow-Origin", origin)523 if CORS_ORIGINS:524 self.send_header("Vary", "Origin")525 self.send_header("Content-Length", str(len(body)))526 self.end_headers()527 self.wfile.write(body)528 529 def do_OPTIONS(self):530 # CORS preflight (POST /api/export from a browser). 204 + headers.531 origin = self._cors_origin()532 self.send_response(204)533 if origin:534 self.send_header("Access-Control-Allow-Origin", origin)535 self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")536 self.send_header("Access-Control-Allow-Headers", "Content-Type, X-API-Key")537 self.send_header("Access-Control-Max-Age", "600")538 if CORS_ORIGINS:539 self.send_header("Vary", "Origin")540 self.send_header("Content-Length", "0")541 self.end_headers()542 543 def do_POST(self):544 self._t0 = time.time(); self._status = 0; sources.reset_fetch_count()545 try:546 self._do_POST()547 finally:548 self._observe(urlparse(self.path).path)549 550 def _do_POST(self):551 u = urlparse(self.path)552 blocked = self._guard(u.path)553 if blocked:554 return self._send(*blocked)555 if u.path != "/api/export" and u.path not in POST_ROUTES:556 return self._send(404, {"status": "error:not found", "see": "/"})557 n = int(self.headers.get("Content-Length") or 0)558 if n > MAX_BODY:559 # drain a BOUNDED amount so the client receives a clean 413 rather560 # than a connection reset; a huge declared body is still capped.561 try:562 self.rfile.read(min(n, MAX_BODY * 8))563 except Exception:564 pass565 return self._send(413, {"status": "error:payload too large (max %d bytes)" % MAX_BODY})566 try:567 raw = self.rfile.read(n) if n > 0 else b""568 body = json.loads(raw or b"{}")569 except (ValueError, json.JSONDecodeError):570 return self._send(400, {"status": "error:invalid JSON body"})571 try:572 if u.path in POST_ROUTES:573 code, obj = POST_ROUTES[u.path](body)574 return self._send(code, obj)575 code, headers, data = h_export(body)576 self._send_bytes(code, headers, data)577 except Exception as e:578 self._send(500, {"status": "error:%s" % str(e)[:200]})579 580 def do_GET(self):581 self._t0 = time.time(); self._status = 0; sources.reset_fetch_count()582 try:583 self._do_GET()584 finally:585 self._observe(urlparse(self.path).path)586 587 def _do_GET(self):588 u = urlparse(self.path)589 if u.path == "/health":590 return self._send(200, {"status": "ok", "requests": _metrics["requests"],591 "errors": _metrics["errors"]})592 if u.path == "/":593 self.send_response(200)594 self.send_header("Content-Type", "text/html; charset=utf-8")595 origin = self._cors_origin()596 if origin:597 self.send_header("Access-Control-Allow-Origin", origin)598 if CORS_ORIGINS:599 self.send_header("Vary", "Origin")600 self.send_header("Content-Length", str(len(_HTML_BODY)))601 self.end_headers()602 self.wfile.write(_HTML_BODY)603 return604 if u.path in ("/api", "/api/index"):605 return self._send(200, INDEX)606 blocked = self._guard(u.path)607 if blocked:608 return self._send(*blocked)609 fn = ROUTES.get(u.path)610 if not fn:611 return self._send(404, {"status": "error:not found", "see": "/api/index"})612 try:613 code, obj = fn(parse_qs(u.query))614 self._send(code, obj)615 except Exception as e:616 self._send(500, {"status": "error:%s" % str(e)[:200]})617 618 def log_message(self, *a):619 pass # quiet620 621 622if __name__ == "__main__":623 if not API_KEY:624 print("!! WARNING: TIDES_API_KEY not set — the API is OPEN (no auth). "625 "Set TIDES_API_KEY for any non-local deployment.")626 if not CORS_ORIGINS:627 print("!! NOTE: TIDES_CORS_ORIGINS not set — CORS is '*' (dev). "628 "Set an allowlist for production.")629 print("TrueTide API + app on http://localhost:%d (open / for the app, /api/index for the endpoint list)" % PORT)630 ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()631 