Packages

A library for validating SpatioTemporal Asset Catalog (STAC) metadata

Current section

Files

Jump to
stac_validator lib stac_validator.ex
Raw

lib/stac_validator.ex

defmodule StacValidator do
@moduledoc """
StacValidator provides functions for validating Spatio-Temporal Asset Catalog (STAC) metadata against the STAC specification.
"""
require Logger
@stac_version "1.1.0"
@stac_schema_url "https://schemas.stacspec.org/v"
@doc """
Validates a STAC item, collection, or catalog against the STAC specification.
## Examples
iex...> StacValidator.validate(%{
"type" => "Feature",
"stac_version" => "1.0.0",
"id" => "example-item",
"geometry" => nil,
"properties" => %{
"datetime" => "2024-01-01T00:00:00Z"
},
"links" => [],
"assets" => %{}
})
{:ok, true}
"""
@spec validate(map()) :: {:ok, boolean()} | {:error, String.t()}
def validate(item, opts \\ [])
def validate(%{"type" => "Feature"} = item, opts) do
version = item["stac_version"] || opts[:stac_version] || @stac_version
with {:ok, schema} <- load_schema("item", version),
:ok <- validate_required_fields(item),
:ok <- validate_against_schema(item, schema),
:ok <- validate_extensions(item, opts[:extensions] || []),
:ok <- validate_business_rules(item) do
{:ok, true}
else
{:error, reason} -> {:error, "Invalid STAC Item: #{reason}"}
end
end
def validate(_unknown, _opts), do: {:error, "Invalid STAC Item: missing required field [type]"}
# Private functions
@required_item_fields ~w(type stac_version id geometry properties)
defp validate_required_fields(item) do
case MapSet.subset?(MapSet.new(@required_item_fields), MapSet.new(Map.keys(item))) do
true ->
:ok
false ->
missing_fields =
MapSet.difference(MapSet.new(@required_item_fields), MapSet.new(Map.keys(item)))
{:error,
"missing required fields [" <> Enum.join(MapSet.to_list(missing_fields), ", ") <> "]"}
end
end
defp load_schema(schema_type, schema_version, opts \\ []) do
case Keyword.get(opts, :schema_source, :remote) do
:local -> load_local_schema(schema_type, schema_version)
:remote -> load_remote_schema(schema_type, schema_version)
end
end
defp load_remote_schema(schema_type, version) do
case HTTPoison.get(stac_schema_url(schema_type, version)) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, Jason.decode!(body)}
{:ok, _} ->
{:error,
"Could not locate schema for the STAC type and version combination, #{schema_type} v#{version}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
defp stac_schema_url(type, version) do
@stac_schema_url <> version <> "/#{type}-spec/json-schema/#{type}.json"
end
defp load_local_schema(schema_type, version) do
schema_path =
Path.join([
:code.priv_dir(:stac_validator),
"schemas",
"v#{version}",
"#{schema_type}-spec",
"#{schema_type}.json"
])
case File.read(schema_path) do
{:ok, schema_json} -> Jason.decode(schema_json)
{:error, _} -> {:error, "schema file not found: #{schema_path}"}
end
end
defp validate_against_schema(%{"type" => "Feature"} = item, schema) do
case ExJsonSchema.Validator.validate(schema, item) do
:ok -> :ok
{:error, errors} -> {:error, format_validation_errors(errors)}
end
end
defp validate_extensions(_item, []), do: :ok
defp validate_extensions(_item, _extensions) do
# TODO: Implement extension validation
:ok
end
defp validate_business_rules(item) do
with :ok <- validate_datetime(item),
:ok <- validate_links(item),
:ok <- validate_assets(item) do
:ok
end
end
defp validate_datetime(%{"properties" => %{"datetime" => datetime}}) do
case DateTime.from_iso8601(datetime) do
{:ok, _datetime, _offset} -> :ok
{:error, _} -> {:error, "invalid datetime format"}
end
end
defp validate_datetime(_), do: {:error, "missing datetime in properties"}
defp validate_links(%{"links" => links}) when is_list(links), do: :ok
defp validate_links(_), do: {:error, "invalid links format"}
defp validate_assets(%{"assets" => assets}) when is_map(assets), do: :ok
defp validate_assets(_), do: {:error, "invalid assets format"}
defp format_validation_errors(errors) when is_list(errors) do
Enum.map_join(errors, "; ", &format_validation_error/1)
end
defp format_validation_error({path, error}) do
"#{path}: #{error}"
end
end