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

13
.gitignore vendored
View File

@@ -23,17 +23,24 @@ erl_crash.dump
/tmp/
# Ignore package tarball (built via "mix hex.build").
docmark-*.tar
docs/*.pdf
data
# Ignore my stack of pdfs
/docs/
# Ignore application logs
/priv/logs/
# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/static/*.js
/priv/static/*.html
/priv/static/*.css
# Ignore mnesia data
Mnesia*/
/priv/mnesia/
# Ignore digested assets cache.
/priv/static/cache_manifest.json

27
config/config.exs Normal file
View File

@@ -0,0 +1,27 @@
import Config
config :mound_hunters,
python_cli_path: System.get_env("PYTHON_CLI_PATH", "./cli.py"),
las2mound_script_path: System.get_env("LAS2MOUND_PATH", "./tooling/las2mound.py"),
tile_output_dir: "data",
tile_temp_dir: "priv/tmp",
mnesia_dir: ~c"priv/mnesia",
http_port: String.to_integer(System.get_env("PORT", "4000")),
log_dir: "priv/logs"
# Logger configuration
config :logger,
backends: [:console, {LoggerFileBackend, :request_log}]
config :logger, :console,
format: "[$level] $message\n",
metadata: [:request_id]
config :logger, :request_log,
path: "priv/logs/requests.jsonl",
level: :info,
format: "$message\n",
metadata: []
# Import environment specific config
import_config "#{config_env()}.exs"

6
config/dev.exs Normal file
View File

@@ -0,0 +1,6 @@
import Config
# Development-specific configuration
config :logger, :console,
format: "[$level] $message\n",
metadata: [:request_id]

3
config/prod.exs Normal file
View File

@@ -0,0 +1,3 @@
import Config
config :logger, level: :info

7
config/test.exs Normal file
View File

@@ -0,0 +1,7 @@
import Config
config :mound_hunters,
http_port: 4001,
mnesia_dir: ~c"priv/mnesia_test"
config :logger, level: :warning

View File

@@ -0,0 +1,112 @@
defmodule MoundHunters.Application do
@moduledoc false
use Application
require Logger
@impl true
def start(_type, _args) do
# Ensure log directory exists
ensure_log_dir()
# Initialize Mnesia before starting supervision tree
setup_mnesia()
children = [
# Tile processing GenServer
MoundHunters.TileProcessor,
# HTTP server
{Plug.Cowboy, scheme: :http, plug: MoundHuntersWeb.Router, options: [port: http_port()]}
]
opts = [strategy: :one_for_one, name: MoundHunters.Supervisor]
Supervisor.start_link(children, opts)
end
defp ensure_log_dir do
log_dir = Application.get_env(:mound_hunters, :log_dir, "priv/logs")
File.mkdir_p!(log_dir)
end
defp http_port do
Application.get_env(:mound_hunters, :http_port, 4000)
end
defp setup_mnesia do
mnesia_dir = Application.get_env(:mound_hunters, :mnesia_dir)
# Ensure mnesia directory exists
mnesia_dir
|> to_string()
|> File.mkdir_p!()
# Stop mnesia if running
:mnesia.stop()
# Create schema if it doesn't exist
case :mnesia.create_schema([node()]) do
:ok ->
Logger.info("Created Mnesia schema")
{:error, {_node1, {:already_exists, _node2}}} ->
Logger.debug("Mnesia schema already exists")
{:error, reason} ->
Logger.warning("Failed to create Mnesia schema: #{inspect(reason)}")
end
# Start Mnesia
:ok = :mnesia.start()
Logger.info("Mnesia started")
# Create tables if they don't exist
create_tables()
end
defp create_tables do
# Tiles table
case :mnesia.create_table(:tiles,
attributes: [
:id,
:min_lat,
:max_lat,
:min_lng,
:max_lng,
:status,
:error_message,
:created_at,
:updated_at
],
disc_copies: [node()],
type: :set
) do
{:atomic, :ok} ->
Logger.info("Created :tiles table")
{:aborted, {:already_exists, :tiles}} ->
Logger.debug(":tiles table already exists")
{:aborted, reason} ->
Logger.error("Failed to create :tiles table: #{inspect(reason)}")
end
# Geometries table
case :mnesia.create_table(:geometries,
attributes: [:id, :geojson, :created_at],
disc_copies: [node()],
type: :set
) do
{:atomic, :ok} ->
Logger.info("Created :geometries table")
{:aborted, {:already_exists, :geometries}} ->
Logger.debug(":geometries table already exists")
{:aborted, reason} ->
Logger.error("Failed to create :geometries table: #{inspect(reason)}")
end
# Wait for tables to be ready
:mnesia.wait_for_tables([:tiles, :geometries], 5000)
end
end

View File

@@ -0,0 +1,121 @@
defmodule MoundHunters.Boundary do
@moduledoc """
Ohio state boundary checking using point-in-polygon algorithm.
"""
# Simplified Ohio bounding box for fast preliminary check
# More precise polygon would be loaded from GeoJSON
@ohio_bbox %{
min_lat: 38.403,
max_lat: 42.327,
min_lng: -84.820,
max_lng: -80.519
}
# Ohio state boundary polygon (simplified)
# Source: Github -> PublicaMundi/MappingAPI us-states.json
# Coordinates are [lng, lat] pairs per GeoJSON spec
@ohio_polygon [
{-80.518598, 41.978802},
{-80.518598, 40.636951},
{-80.666475, 40.582182},
{-80.595275, 40.472643},
{-80.600752, 40.319289},
{-80.737675, 40.078303},
{-80.830783, 39.711348},
{-81.219646, 39.388209},
{-81.345616, 39.344393},
{-81.455155, 39.410117},
{-81.570170, 39.267716},
{-81.685186, 39.273193},
{-81.811156, 39.081500},
{-81.783771, 38.966484},
{-81.887833, 38.873376},
{-82.035710, 39.026731},
{-82.221926, 38.785745},
{-82.172634, 38.632391},
{-82.293127, 38.577622},
{-82.331465, 38.446175},
{-82.594358, 38.424267},
{-82.731282, 38.561191},
{-82.846298, 38.588575},
{-82.890113, 38.758361},
{-83.032514, 38.725499},
{-83.142052, 38.626914},
{-83.519961, 38.703591},
{-83.678792, 38.632391},
{-83.903347, 38.769315},
{-84.215533, 38.807653},
{-84.231963, 38.895284},
{-84.434610, 39.103408},
{-84.817996, 39.103408},
{-84.801565, 40.500028},
{-84.807042, 41.694001},
{-83.454238, 41.732339},
{-83.065375, 41.595416},
{-82.933929, 41.513262},
{-82.835344, 41.589939},
{-82.616266, 41.431108},
{-82.479343, 41.381815},
{-82.013803, 41.513262},
{-81.739956, 41.485877},
{-81.444201, 41.672093},
{-81.011523, 41.852832},
{-80.518598, 41.978802},
{-80.518598, 41.978802}
]
@doc """
Check if coordinates are within Ohio boundaries.
Returns :ok or {:error, reason}
"""
def check_bounds(lat, lng) when is_number(lat) and is_number(lng) do
cond do
not in_bounding_box?(lat, lng) ->
{:error, "Coordinates outside Ohio bounding box"}
not in_polygon?(lng, lat, @ohio_polygon) ->
{:error, "Coordinates outside Ohio boundary"}
true ->
:ok
end
end
def check_bounds(_lat, _lng) do
{:error, "Invalid coordinates"}
end
defp in_bounding_box?(lat, lng) do
lat >= @ohio_bbox.min_lat and lat <= @ohio_bbox.max_lat and
lng >= @ohio_bbox.min_lng and lng <= @ohio_bbox.max_lng
end
# Ray casting algorithm for point-in-polygon test
# Returns true if point (x, y) is inside the polygon
defp in_polygon?(x, y, polygon) do
n = length(polygon)
polygon
|> Enum.with_index()
|> Enum.reduce(false, fn {{xi, yi}, i}, inside ->
j = rem(i + n - 1, n)
{xj, yj} = Enum.at(polygon, j)
intersects =
yi > y != yj > y and
x < (xj - xi) * (y - yi) / (yj - yi) + xi
if intersects, do: not inside, else: inside
end)
end
@doc """
Format coordinates to 6 decimal places for consistent lookups.
"""
def format_lookup_id(lat, lng) do
lat_str = :erlang.float_to_binary(lat / 1.0, decimals: 6)
lng_str = :erlang.float_to_binary(lng / 1.0, decimals: 6)
"#{lat_str},#{lng_str}"
end
end

View File

@@ -0,0 +1,196 @@
defmodule MoundHunters.OhioLidar do
@moduledoc """
Functions for querying Ohio's ArcGIS tile service and coordinate conversions.
"""
require Logger
# Ohio ArcGIS tile service
@tile_service_url "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query"
# OGRIP download URL template
@download_url_template "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip"
@doc """
Convert lon/lat (WGS84) to Web Mercator (EPSG:3857).
Formula from: https://en.wikipedia.org/wiki/Web_Mercator_projection
"""
def lonlat_to_webmercator(lon, lat) do
# Earth radius in meters
r = 6378137.0
# Convert to radians
lon_rad = lon * :math.pi() / 180.0
lat_rad = lat * :math.pi() / 180.0
# Web Mercator formulas
x = r * lon_rad
y = r * :math.log(:math.tan(:math.pi() / 4.0 + lat_rad / 2.0))
{x, y}
end
@doc """
Query Ohio ArcGIS service for tile information at given coordinates.
Returns {:ok, tile_info} or {:error, reason}
tile_info contains: %{
tile_name: "BS19820747",
county: "LIC",
year: "2020",
block: "4",
note: "..."
}
"""
def query_tile_info(lon, lat) do
{x, y} = lonlat_to_webmercator(lon, lat)
geometry =
Jason.encode!(%{
x: x,
y: y,
spatialReference: %{wkid: 3857}
})
params = %{
"f" => "json",
"returnGeometry" => "false",
"spatialRel" => "esriSpatialRelIntersects",
"geometry" => geometry,
"geometryType" => "esriGeometryPoint",
"inSR" => "3857",
"outFields" => "*",
"outSR" => "3857"
}
Logger.debug("Querying ArcGIS for tile at (#{lon}, #{lat}) -> WebMercator (#{x}, #{y})")
case HTTPoison.get(@tile_service_url, [], params: params, timeout: 10_000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
parse_tile_response(body)
{:ok, %HTTPoison.Response{status_code: status}} ->
Logger.error("ArcGIS returned status #{status}")
{:error, "ArcGIS service returned status #{status}"}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to query ArcGIS: #{inspect(reason)}")
{:error, "Network error: #{inspect(reason)}"}
end
end
defp parse_tile_response(body) do
case Jason.decode(body) do
{:ok, %{"features" => []}} ->
{:error, :no_tile_found}
{:ok, %{"features" => [feature | _]}} ->
attrs = feature["attributes"]
tile_info = %{
tile_name: attrs["TileName"],
county: attrs["County"],
year: attrs["Year"],
block: attrs["Block"],
note: attrs["note"]
}
{:ok, tile_info}
{:ok, _} ->
{:error, :invalid_response}
{:error, _} ->
{:error, :json_parse_error}
end
end
@doc """
Get the download URL for a tile.
"""
def get_download_url(tile_name, county) do
@download_url_template
|> String.replace("{county}", county)
|> String.replace("{tile_name}", tile_name)
end
@doc """
Download a tile ZIP file from OGRIP.
Returns {:ok, file_path} or {:error, reason}
"""
def download_tile(tile_name, county, output_path) do
url = get_download_url(tile_name, county)
Logger.info("Downloading #{tile_name} from #{url}")
# Ensure output directory exists
output_path
|> Path.dirname()
|> File.mkdir_p!()
case HTTPoison.get(url, [], timeout: 60_000, recv_timeout: 60_000, stream_to: self()) do
{:ok, %HTTPoison.AsyncResponse{id: id}} ->
receive_download(id, output_path, 0)
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to start download: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"}
end
end
defp receive_download(id, output_path, bytes_received) do
receive do
%HTTPoison.AsyncStatus{id: ^id, code: 200} ->
Logger.debug("Download started, status 200")
receive_download(id, output_path, bytes_received)
%HTTPoison.AsyncStatus{id: ^id, code: status} ->
Logger.error("Download failed with status #{status}")
{:error, "HTTP status #{status}"}
%HTTPoison.AsyncHeaders{id: ^id} ->
# Start writing to file
File.open(output_path, [:write, :binary], fn file ->
receive_download_chunks(id, file, bytes_received)
end)
%HTTPoison.AsyncEnd{id: ^id} ->
Logger.error("Download ended prematurely")
{:error, :unexpected_end}
{:error, reason} ->
Logger.error("Download error: #{inspect(reason)}")
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
defp receive_download_chunks(id, file, bytes_received) do
receive do
%HTTPoison.AsyncChunk{id: ^id, chunk: chunk} ->
IO.binwrite(file, chunk)
new_bytes = bytes_received + byte_size(chunk)
# Log progress every 10MB
if div(new_bytes, 10_485_760) > div(bytes_received, 10_485_760) do
Logger.debug("Downloaded #{div(new_bytes, 1_048_576)} MB")
end
receive_download_chunks(id, file, new_bytes)
%HTTPoison.AsyncEnd{id: ^id} ->
size_mb = bytes_received / 1_048_576
Logger.info("Download complete: #{Float.round(size_mb, 2)} MB")
{:ok, bytes_received}
{:error, reason} ->
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
end

107
lib/mound_hunters/repo.ex Normal file
View File

@@ -0,0 +1,107 @@
defmodule MoundHunters.Repo do
@moduledoc """
Mnesia database helpers for tiles and geometries.
"""
require Logger
# Tile statuses
@type tile_status :: :processing | :ready | :error
@doc """
Get a tile by ID from Mnesia.
"""
def get_tile(tile_id) do
case :mnesia.transaction(fn ->
:mnesia.read(:tiles, tile_id)
end) do
{:atomic, [tile]} -> {:ok, tile_to_map(tile)}
{:atomic, []} -> {:error, :not_found}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Insert or update a tile record.
"""
def upsert_tile(attrs) do
tile_id = Map.fetch!(attrs, :id)
now = System.system_time(:second)
tile_record =
{:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat),
Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status),
Map.get(attrs, :error_message), Map.get(attrs, :created_at, now), now}
case :mnesia.transaction(fn ->
:mnesia.write(tile_record)
end) do
{:atomic, :ok} -> {:ok, tile_to_map(tile_record)}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Get a shared geometry by ID.
"""
def get_geometry(geometry_id) do
case :mnesia.transaction(fn ->
:mnesia.read(:geometries, geometry_id)
end) do
{:atomic, [geometry]} -> {:ok, geometry_to_map(geometry)}
{:atomic, []} -> {:error, :not_found}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Create a new shared geometry.
"""
def create_geometry(geojson) do
geometry_id = generate_id()
now = System.system_time(:second)
geometry_record = {:geometries, geometry_id, geojson, now}
case :mnesia.transaction(fn ->
:mnesia.write(geometry_record)
end) do
{:atomic, :ok} -> {:ok, %{id: geometry_id}}
{:aborted, reason} -> {:error, reason}
end
end
# Convert Mnesia tile record to map
defp tile_to_map(
{:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, created_at,
updated_at}
) do
%{
id: id,
min_lat: min_lat,
max_lat: max_lat,
min_lng: min_lng,
max_lng: max_lng,
status: status,
error_message: error_message,
created_at: created_at,
updated_at: updated_at
}
end
# Convert Mnesia geometry record to map
defp geometry_to_map({:geometries, id, geojson, created_at}) do
%{
id: id,
geojson: geojson,
created_at: created_at
}
end
# Generate random 8-character alphanumeric ID
defp generate_id do
:crypto.strong_rand_bytes(6)
|> Base.url_encode64(padding: false)
|> binary_part(0, 8)
end
end

View File

@@ -0,0 +1,335 @@
defmodule MoundHunters.TileProcessor do
@moduledoc """
GenServer that processes tile requests: lookup tile IDs and download/convert tiles.
Processing pipeline:
1. :looking_up - Query ArcGIS for tile metadata
2. :downloading - Download ZIP from OGRIP
3. :extracting - Unzip the archive
4. :converting - Run las2mound.py to create .mound file
5. :done - Files ready in priv/tiles/
"""
use GenServer
require Logger
alias MoundHunters.OhioLidar
# Processing statuses
@type lookup_status :: :pending | {:ok, String.t()} | {:error, String.t()}
@type processing_status ::
:queued | :downloading | :extracting | :converting | :done | {:error, String.t()}
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Request processing for a lat/lng coordinate pair.
Returns the lookup_id that can be used to poll progress.
"""
def request_tile(lat, lng) do
lookup_id = MoundHunters.Boundary.format_lookup_id(lat, lng)
GenServer.cast(__MODULE__, {:request_tile, lookup_id, lat, lng})
{:ok, lookup_id}
end
@doc """
Get the current lookup status for a coordinate pair.
"""
def get_lookup_status(lookup_id) do
case :ets.lookup(:tile_lookups, lookup_id) do
[{^lookup_id, status}] -> {:ok, status}
[] -> {:error, :not_found}
end
end
@doc """
Get the current processing status for a tile.
"""
def get_processing_status(tile_id) do
case :ets.lookup(:tile_processing, tile_id) do
[{^tile_id, status, _metadata}] -> {:ok, status}
[] -> {:error, :not_found}
end
end
@doc """
Get full metadata for a processing tile.
"""
def get_processing_metadata(tile_id) do
case :ets.lookup(:tile_processing, tile_id) do
[{^tile_id, status, metadata}] -> {:ok, status, metadata}
[] -> {:error, :not_found}
end
end
# Server callbacks
@impl true
def init(_opts) do
# Create ETS tables
:ets.new(:tile_lookups, [:set, :public, :named_table])
:ets.new(:tile_processing, [:set, :public, :named_table])
# Ensure temp directory exists
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
File.mkdir_p!(temp_dir)
state = %{
las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path),
tile_output_dir: Application.get_env(:mound_hunters, :tile_output_dir),
tile_temp_dir: temp_dir,
processing_queue: :queue.new(),
current_job: nil
}
Logger.info("TileProcessor started")
{:ok, state}
end
@impl true
def handle_cast({:request_tile, lookup_id, lat, lng}, state) do
# Check if we already have this lookup in progress or completed
case :ets.lookup(:tile_lookups, lookup_id) do
[] ->
# New request - insert as pending and start lookup
:ets.insert(:tile_lookups, {lookup_id, :pending})
Logger.info("New tile request for #{lookup_id} (#{lat}, #{lng})")
# Start lookup asynchronously
send(self(), {:lookup_tile, lookup_id, lat, lng})
[{^lookup_id, _status}] ->
# Already in progress or completed, ignore duplicate request
Logger.debug("Duplicate tile request for #{lookup_id}, ignoring")
end
{:noreply, state}
end
@impl true
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
case OhioLidar.query_tile_info(lng, lat) do
{:ok, tile_info} ->
tile_name = tile_info.tile_name
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
# Update lookup table with success
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
# Check if tile already exists
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
if File.exists?(output_file) do
Logger.info("Tile #{tile_name} already processed, marking as done")
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
else
# Queue for processing
state = queue_tile_for_processing(state, tile_name, tile_info)
{:noreply, state}
end
{:error, :no_tile_found} ->
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to lookup tile: #{inspect(reason)}")
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
{:noreply, state}
end
end
@impl true
def handle_info({:process_tile, tile_name, tile_info}, state) do
Logger.info("Starting processing for tile #{tile_name}")
# Update status to downloading
:ets.insert(:tile_processing, {tile_name, :downloading, %{tile_info: tile_info}})
# Download ZIP
temp_zip = Path.join(state.tile_temp_dir, "#{tile_name}.zip")
case OhioLidar.download_tile(tile_name, tile_info.county, temp_zip) do
{:ok, _bytes} ->
send(self(), {:extract_tile, tile_name, tile_info, temp_zip})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to download tile #{tile_name}: #{inspect(reason)}")
:ets.insert(:tile_processing, {tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}})
# Clean up
File.rm(temp_zip)
# Process next in queue
state = process_next_in_queue(state)
{:noreply, state}
end
end
@impl true
def handle_info({:extract_tile, tile_name, tile_info, zip_path}, state) do
Logger.info("Extracting tile #{tile_name}")
:ets.insert(:tile_processing, {tile_name, :extracting, %{tile_info: tile_info}})
extract_dir = Path.join(state.tile_temp_dir, tile_name)
File.mkdir_p!(extract_dir)
case unzip_file(zip_path, extract_dir) do
:ok ->
# Find the .las file
case find_las_file(extract_dir) do
{:ok, las_file} ->
send(self(), {:convert_tile, tile_name, tile_info, las_file, extract_dir})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to find LAS file in #{extract_dir}: #{reason}")
:ets.insert(:tile_processing, {tile_name, {:error, "No LAS file found"}, %{}})
# Clean up
File.rm_rf!(extract_dir)
File.rm(zip_path)
state = process_next_in_queue(state)
{:noreply, state}
end
{:error, reason} ->
Logger.error("Failed to extract #{zip_path}: #{inspect(reason)}")
:ets.insert(:tile_processing, {tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}})
# Clean up
File.rm_rf!(extract_dir)
File.rm(zip_path)
state = process_next_in_queue(state)
{:noreply, state}
end
end
@impl true
def handle_info({:convert_tile, tile_name, tile_info, las_file, temp_dir}, state) do
Logger.info("Converting tile #{tile_name} to mound format")
:ets.insert(:tile_processing, {tile_name, :converting, %{tile_info: tile_info}})
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
File.mkdir_p!(state.tile_output_dir)
case run_las2mound(state.las2mound_script, las_file, output_file) do
:ok ->
Logger.info("Successfully converted #{tile_name}")
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
# Update Mnesia
MoundHunters.Repo.upsert_tile(%{
id: tile_name,
status: :ready,
min_lat: nil, # TODO: Extract from tile_info or LAS bounds
max_lat: nil,
min_lng: nil,
max_lng: nil
})
# Clean up temp files
File.rm_rf!(temp_dir)
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
state = process_next_in_queue(state)
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to convert #{tile_name}: #{inspect(reason)}")
:ets.insert(:tile_processing, {tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}})
# Clean up
File.rm_rf!(temp_dir)
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
state = process_next_in_queue(state)
{:noreply, state}
end
end
# Helper functions
defp queue_tile_for_processing(state, tile_name, tile_info) do
# Check if already in queue or processing
case :ets.lookup(:tile_processing, tile_name) do
[] ->
# Add to queue
:ets.insert(:tile_processing, {tile_name, :queued, %{tile_info: tile_info}})
new_queue = :queue.in({tile_name, tile_info}, state.processing_queue)
state = %{state | processing_queue: new_queue}
# If nothing currently processing, start now
if state.current_job == nil do
process_next_in_queue(state)
else
state
end
[{^tile_name, _status, _metadata}] ->
Logger.debug("Tile #{tile_name} already in processing queue")
state
end
end
defp process_next_in_queue(state) do
case :queue.out(state.processing_queue) do
{{:value, {tile_name, tile_info}}, new_queue} ->
Logger.info("Processing next tile in queue: #{tile_name}")
send(self(), {:process_tile, tile_name, tile_info})
%{state | processing_queue: new_queue, current_job: tile_name}
{:empty, _} ->
Logger.debug("Processing queue empty")
%{state | current_job: nil}
end
end
defp unzip_file(zip_path, extract_dir) do
case :zip.unzip(String.to_charlist(zip_path), cwd: String.to_charlist(extract_dir)) do
{:ok, _files} ->
:ok
{:error, reason} ->
{:error, reason}
end
end
defp find_las_file(dir) do
case File.ls(dir) do
{:ok, files} ->
las_files = Enum.filter(files, &String.ends_with?(&1, ".las"))
case las_files do
[las_file | _] -> {:ok, Path.join(dir, las_file)}
[] -> {:error, :no_las_file}
end
{:error, reason} ->
{:error, reason}
end
end
defp run_las2mound(script_path, input_las, output_mound) do
Logger.info("Running: #{script_path} #{input_las} #{output_mound}")
case System.cmd("python3", [script_path, input_las, output_mound], stderr_to_stdout: true) do
{output, 0} ->
Logger.debug("las2mound output: #{output}")
:ok
{output, exit_code} ->
Logger.error("las2mound failed with exit code #{exit_code}: #{output}")
{:error, "Exit code #{exit_code}"}
end
end
end

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

31
mix.exs Normal file
View File

@@ -0,0 +1,31 @@
defmodule MoundHunters.MixProject do
use Mix.Project
def project do
[
app: :mound_hunters,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger, :mnesia, :inets, :ssl],
mod: {MoundHunters.Application, []}
]
end
defp deps do
[
{:plug_cowboy, "~> 2.7"},
{:jason, "~> 1.4"},
{:geo, "~> 3.6"},
{:logger_file_backend, "~> 0.0.13"},
{:httpoison, "~> 2.2"},
{:mime, "~> 2.0"}
]
end
end

23
mix.lock Normal file
View File

@@ -0,0 +1,23 @@
%{
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"geo": {:hex, :geo, "3.6.0", "00c9c6338579f67e91cd5950af4ae2eb25cdce0c3398718c232539f61625d0bd", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1dbdebf617183b54bc3c8ad7a36531a9a76ada8ca93f75f573b0ae94006168da"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
}