create backend
This commit is contained in:
72
lib/mound_hunters_web/controllers/api_controller.ex
Normal file
72
lib/mound_hunters_web/controllers/api_controller.ex
Normal 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
|
||||
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal 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
|
||||
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal file
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal 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
|
||||
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal file
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal 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
|
||||
158
lib/mound_hunters_web/router.ex
Normal file
158
lib/mound_hunters_web/router.ex
Normal 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
|
||||
Reference in New Issue
Block a user