create backend

This commit is contained in:
2026-01-24 10:23:37 +01:00
parent 73625bf6a5
commit f059b54936
17 changed files with 1611 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
defmodule MoundHuntersWeb.ApiController do
@moduledoc """
API endpoints for sharing geometries.
"""
import Plug.Conn
require Logger
@doc """
Create a new shared geometry.
POST /api/share
Body: {"geojson": {...}}
Returns: {"id": "abc12345"}
"""
def create_share(conn) do
with {:ok, body, conn} <- read_body(conn),
{:ok, params} <- Jason.decode(body),
geojson when is_map(geojson) <- params["geojson"],
geojson_str <- Jason.encode!(geojson),
{:ok, %{id: id}} <- MoundHunters.Repo.create_geometry(geojson_str) do
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "no-cache")
|> send_resp(200, Jason.encode!(%{id: id}))
else
{:error, :invalid_json} ->
send_error(conn, 400, "Invalid JSON")
nil ->
send_error(conn, 400, "Missing geojson field")
{:error, reason} ->
Logger.error("Failed to create geometry: #{inspect(reason)}")
send_error(conn, 500, "Failed to create geometry")
_ ->
send_error(conn, 400, "Invalid request")
end
end
@doc """
Get a shared geometry by ID.
GET /api/share/:id
Returns: {"geojson": {...}}
"""
def get_share(conn) do
geometry_id = conn.path_params["id"]
case MoundHunters.Repo.get_geometry(geometry_id) do
{:ok, geometry} ->
geojson = Jason.decode!(geometry.geojson)
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "no-cache")
|> send_resp(200, Jason.encode!(%{geojson: geojson}))
{:error, :not_found} ->
send_error(conn, 404, "Geometry not found")
{:error, reason} ->
Logger.error("Failed to get geometry: #{inspect(reason)}")
send_error(conn, 500, "Failed to retrieve geometry")
end
end
defp send_error(conn, status, message) do
conn
|> put_private(:error_message, message)
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
end
end

View File

@@ -0,0 +1,227 @@
defmodule MoundHuntersWeb.TileController do
@moduledoc """
Handles tile requests via Server-Sent Events and serves tile files.
"""
import Plug.Conn
require Logger
@doc """
SSE endpoint for tile requests.
GET /tiles/request?lat=40.0&lng=-82.5
Flow:
1. Validate coords (done by BoundsCheck plug)
2. Format lookup_id from lat/lng
3. Request tile processing
4. Poll :tile_lookups until we get tile_id or error
5. Poll :tile_processing for status updates
6. Send SSE events for progress
7. When done, send final event with tile_id and URLs
"""
def request(conn) do
lat = String.to_float(conn.query_params["lat"])
lng = String.to_float(conn.query_params["lng"])
# Start SSE stream
conn =
conn
|> put_resp_header("content-type", "text/event-stream")
|> put_resp_header("cache-control", "no-cache")
|> put_resp_header("connection", "keep-alive")
|> send_chunked(200)
# Request tile processing
{:ok, lookup_id} = MoundHunters.TileProcessor.request_tile(lat, lng)
Logger.info("Tile request started for #{lookup_id}")
# Store lookup_id in conn for logging
conn = put_private(conn, :lookup_id, lookup_id)
# Poll for lookup completion
tile_id =
case poll_lookup(conn, lookup_id) do
{:ok, id} ->
id
{:error, reason} ->
conn = put_private(conn, :error_message, reason)
send_sse_event(conn, %{status: "error", message: reason})
nil
end
# If we got a tile_id, poll for processing status
if tile_id do
conn = put_private(conn, :tile_id, tile_id)
poll_processing(conn, tile_id)
end
conn
end
@doc """
Serve a tile file.
GET /tiles/MOUND/:tile_id.mound
GET /tiles/JPG/:tile_id.jpg
GET /tiles/PNG/:tile_id.jpng
GET /tiles/JSON/:tile_id.json
"""
def serve(conn) do
raw_tile_id = conn.path_params["tile_id"]
format = conn.path_params["format"]
# Strip extension if present
tile_id =
raw_tile_id
|> Path.rootname()
# Store tile_id for logging
conn = put_private(conn, :tile_id, tile_id)
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
file_path =
case format do
"MOUND" -> Path.join(tile_dir, "MOUND/#{tile_id}.mound")
"JSON" -> Path.join(tile_dir, "JSON/#{tile_id}.json")
"JPG" -> Path.join(tile_dir, "JPG/#{tile_id}.jpg")
"PNG" -> Path.join(tile_dir, "PNG/#{tile_id}.png")
end
Logger.info("Tile request started for #{file_path}")
if File.exists?(file_path) do
conn
|> put_resp_header("content-type", MIME.from_path(file_path))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_file(200, file_path)
else
conn
|> put_private(:error_message, "Tile file not found")
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(%{error: "Tile file not found"}))
end
end
# Poll :tile_lookups ETS table until we get a tile_id or error
defp poll_lookup(conn, lookup_id, attempts \\ 0) do
# 60 seconds at 500ms intervals
max_attempts = 120
case MoundHunters.TileProcessor.get_lookup_status(lookup_id) do
{:ok, :pending} ->
if attempts < max_attempts do
send_sse_event(conn, %{status: "looking_up", message: "Finding tile..."})
Process.sleep(500)
poll_lookup(conn, lookup_id, attempts + 1)
else
{:error, "Lookup timeout"}
end
{:ok, {:ok, tile_id}} ->
send_sse_event(conn, %{status: "found", tile_id: tile_id})
{:ok, tile_id}
{:ok, {:error, reason}} ->
{:error, reason}
{:error, :not_found} ->
# Request hasn't been picked up yet
if attempts < max_attempts do
Process.sleep(500)
poll_lookup(conn, lookup_id, attempts + 1)
else
{:error, "Lookup not started"}
end
end
end
# Poll :tile_processing ETS table for status updates
defp poll_processing(conn, tile_id, last_status \\ nil, attempts \\ 0) do
# 5 minutes at 500ms intervals
max_attempts = 600
case MoundHunters.TileProcessor.get_processing_status(tile_id) do
{:ok, status} when status != last_status ->
# Status changed, send update
event = build_status_event(status, tile_id)
send_sse_event(conn, event)
case status do
:done ->
# Processing complete
:ok
{:error, _reason} ->
# Error occurred, stop polling
:ok
_ ->
# Continue polling
if attempts < max_attempts do
Process.sleep(500)
poll_processing(conn, tile_id, status, attempts + 1)
else
send_sse_event(conn, %{status: "error", message: "Processing timeout"})
end
end
{:ok, status} ->
# Status unchanged, keep polling
if attempts < max_attempts do
Process.sleep(500)
poll_processing(conn, tile_id, status, attempts + 1)
else
send_sse_event(conn, %{status: "error", message: "Processing timeout"})
end
{:error, :not_found} ->
# Tile not in processing queue yet
if attempts < max_attempts do
Process.sleep(500)
poll_processing(conn, tile_id, last_status, attempts + 1)
else
send_sse_event(conn, %{status: "error", message: "Tile not queued"})
end
end
end
defp build_status_event(:queued, _tile_id) do
%{status: "processing", message: "Queued for processing..."}
end
defp build_status_event(:looking_up, _tile_id) do
%{status: "processing", message: "Looking up tile information..."}
end
defp build_status_event(:downloading, _tile_id) do
%{status: "processing", message: "Downloading lidar data..."}
end
defp build_status_event(:extracting, _tile_id) do
%{status: "processing", message: "Extracting point cloud..."}
end
defp build_status_event(:converting, _tile_id) do
%{status: "processing", message: "Converting to mound format..."}
end
defp build_status_event(:done, tile_id) do
%{
status: "ready",
tile_id: tile_id,
urls: %{
mound: "/tiles/#{tile_id}.mound",
jpg: "/tiles/#{tile_id}.jpg"
}
}
end
defp build_status_event({:error, reason}, _tile_id) do
%{status: "error", message: to_string(reason)}
end
defp send_sse_event(conn, data) do
json = Jason.encode!(data)
chunk(conn, "data: #{json}\n\n")
end
end

View File

@@ -0,0 +1,75 @@
defmodule MoundHuntersWeb.Plugs.BoundsCheck do
@moduledoc """
Plug to validate that coordinates are within Ohio boundaries.
Applies to tile requests but not to geometry sharing API.
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
# Skip bounds check for non-tile endpoints
if skip_bounds_check?(conn.request_path) do
conn
else
check_bounds(conn)
end
end
defp skip_bounds_check?(path) do
# Skip bounds check for geometry API and static files
String.starts_with?(path, "/api/share") or
String.starts_with?(path, "/static/") or
path == "/"
end
defp check_bounds(conn) do
cond do
# Check query params for tile request
conn.query_params["lat"] != nil and conn.query_params["lng"] != nil ->
check_query_params(conn)
# For tile file serving, we assume tiles in storage are valid
# (they were validated when created)
String.starts_with?(conn.request_path, "/tiles/") ->
conn
true ->
conn
end
end
defp check_query_params(conn) do
with {:ok, lat} <- parse_float(conn.query_params["lat"]),
{:ok, lng} <- parse_float(conn.query_params["lng"]),
:ok <- MoundHunters.Boundary.check_bounds(lat, lng) do
conn
else
{:error, :invalid_number} ->
send_error(conn, 400, "Invalid coordinate format")
{:error, reason} ->
send_error(conn, 400, reason)
end
end
defp parse_float(str) when is_binary(str) do
case Float.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, :invalid_number}
end
end
defp parse_float(_), do: {:error, :invalid_number}
defp send_error(conn, status, message) do
Logger.warning("Bounds check failed: #{message}")
conn
|> put_private(:error_message, message)
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
|> halt()
end
end

View File

@@ -0,0 +1,101 @@
defmodule MoundHuntersWeb.Plugs.RequestLogger do
@moduledoc """
Logs each HTTP request as a single JSON line in combined log format plus custom fields.
Log format includes:
- Standard combined log fields: remote_ip, timestamp, method, path, status, bytes, referer, user_agent
- Custom fields: request_id, duration_ms, query_params, lat, lng, tile_id, lookup_id, error
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
start_time = System.monotonic_time(:microsecond)
request_id = generate_request_id()
conn =
conn
|> put_private(:request_start_time, start_time)
|> put_private(:request_id, request_id)
register_before_send(conn, fn conn ->
log_request(conn, start_time, request_id)
conn
end)
end
defp log_request(conn, start_time, request_id) do
end_time = System.monotonic_time(:microsecond)
duration_ms = (end_time - start_time) / 1000.0
log_entry = %{
# Standard combined log format fields
remote_ip: format_remote_ip(conn.remote_ip),
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
method: conn.method,
path: conn.request_path,
query_string: conn.query_string,
status: conn.status,
bytes_sent: get_resp_header(conn, "content-length") |> List.first() || "-",
referer: get_req_header(conn, "referer") |> List.first() || "-",
user_agent: get_req_header(conn, "user-agent") |> List.first() || "-",
# Custom fields
request_id: request_id,
duration_ms: Float.round(duration_ms, 2),
# Tile-specific fields (if present)
lat: get_query_param(conn, "lat"),
lng: get_query_param(conn, "lng"),
tile_id: get_private_field(conn, :tile_id),
lookup_id: get_private_field(conn, :lookup_id),
# Error information
error: get_private_field(conn, :error_message)
}
# Remove nil values for cleaner logs
log_entry = Enum.reject(log_entry, fn {_k, v} -> is_nil(v) end) |> Map.new()
# Log to file as JSON line
json_line = Jason.encode!(log_entry)
Logger.info(json_line, logger: :request_log)
# Also log summary to console
console_msg =
"#{conn.method} #{conn.request_path} - #{conn.status} - #{Float.round(duration_ms, 2)}ms"
Logger.info(console_msg)
end
defp format_remote_ip({a, b, c, d}) do
"#{a}.#{b}.#{c}.#{d}"
end
defp format_remote_ip({a, b, c, d, e, f, g, h}) do
parts = [a, b, c, d, e, f, g, h]
parts
|> Enum.map(&Integer.to_string(&1, 16))
|> Enum.join(":")
end
defp get_query_param(conn, key) do
case conn.query_params do
%{^key => value} when value != "" -> value
_ -> nil
end
end
defp get_private_field(conn, key) do
case conn.private do
%{^key => value} -> value
_ -> nil
end
end
defp generate_request_id do
:crypto.strong_rand_bytes(8)
|> Base.url_encode64(padding: false)
end
end

View File

@@ -0,0 +1,158 @@
defmodule MoundHuntersWeb.Router do
@moduledoc """
Main HTTP router for Hopewell Road application.
"""
use Plug.Router
require Logger
# Request logging (must be first to capture timing)
plug(MoundHuntersWeb.Plugs.RequestLogger)
# Parse query params and request body
plug(Plug.Parsers,
parsers: [:urlencoded, :json],
json_decoder: Jason
)
# Bounds checking for Ohio coordinates
plug(MoundHuntersWeb.Plugs.BoundsCheck)
# Match routes
plug(:match)
# Dispatch to matched route
plug(:dispatch)
# Tile endpoints
get "/tiles/request" do
MoundHuntersWeb.TileController.request(conn)
end
match "/tiles/:format/:tile_id", via: [:get, :head] do
conn = put_private(conn, :path_params, %{"tile_id" => tile_id, "format" => format})
MoundHuntersWeb.TileController.serve(conn)
end
# Geometry sharing API
post "/api/share" do
MoundHuntersWeb.ApiController.create_share(conn)
end
get "/api/share/:id" do
conn = put_private(conn, :path_params, %{"id" => id})
MoundHuntersWeb.ApiController.get_share(conn)
end
# Health check
get "/health" do
send_resp(conn, 200, Jason.encode!(%{status: "ok"}))
end
get "/" do
static_dir = Application.app_dir(:mound_hunters, "priv/static")
index_path = Path.join(static_dir, "index.html")
if File.exists?(index_path) do
conn
|> put_resp_header("content-type", "text/html; charset=utf-8")
|> put_resp_header("cache-control", "no-cache")
|> send_file(200, index_path)
else
send_resp(conn, 404, "Not found")
end
end
# Static files (with cache busting support via manifest)
get "/*path" do
serve_static(conn, path)
end
# 404 handler
match _ do
send_resp(conn, 404, Jason.encode!(%{error: "Not found"}))
end
# Serve static files from priv/static
defp serve_static(conn, path) do
static_dir = Application.app_dir(:mound_hunters, "priv/static")
file_path = resolve_static_path(path, static_dir)
cond do
file_path && File.regular?(file_path) ->
# Determine content type
content_type = MIME.from_path(file_path)
# Set cache headers (immutable for hashed files)
cache_control =
if String.contains?(file_path, "-") do
"public, max-age=31536000, immutable"
else
"public, max-age=3600"
end
conn
|> put_resp_header("content-type", content_type)
|> put_resp_header("cache-control", cache_control)
|> send_file(200, file_path)
path == [] or path == [""] ->
# Serve index.html for root
index_path = Path.join(static_dir, "index.html")
if File.exists?(index_path) do
conn
|> put_resp_header("content-type", "text/html")
|> put_resp_header("cache-control", "no-cache")
|> send_file(200, index_path)
else
send_resp(conn, 404, "Not found")
end
true ->
send_resp(conn, 404, "Not found")
end
end
# Resolve static file path, checking manifest for hashed versions
defp resolve_static_path(path, static_dir) do
requested = Path.join(path)
direct_path = Path.join(static_dir, requested)
cond do
File.regular?(direct_path) ->
direct_path
# Check manifest for hashed version
manifest_path = Path.join(static_dir, "manifest.json") ->
case load_manifest(manifest_path) do
{:ok, manifest} ->
hashed = Map.get(manifest, requested)
if hashed do
hashed_path = Path.join(static_dir, hashed)
if File.regular?(hashed_path), do: hashed_path, else: nil
else
nil
end
_ ->
nil
end
true ->
nil
end
end
# Load and cache manifest
defp load_manifest(path) do
if File.exists?(path) do
case File.read(path) do
{:ok, content} -> Jason.decode(content)
_ -> {:error, :read_failed}
end
else
{:error, :not_found}
end
end
end