This Phoenix application provides a STAC (SpatioTemporal Asset Catalog) API implementation with both REST API endpoints and HTML browser interface for exploring geospatial data catalogs.
- Fork the repository from GitHub.
- Create a feature branch for your changes.
- Submit a Pull Request to the
masterbranch. - Keep pull requests focused on specific updates.
# git clone
# cd folder
mix deps.get
# mix ecto.setup for local docker dev could work (but have postgres db credentials
)
mix assets.setup
mix assets.build
mix compile
mix assets.deploy
mix ecto.migrate
mix test
We maintain an internal GitLab (origin) and a public GitHub mirror (outbound). To keep histories identical across both while using rebases, follow the "Mirroring Workflow":
Create a combined all remote that pushes to both GitLab and GitHub:
git remote add all git@gitlab.ut.ee:geog/stac_api_ex.git
git remote set-url --add --push all git@gitlab.ut.ee:geog/stac_api_ex.git
git remote set-url --add --push all git@github.com:LandscapeGeoinformatics/stac_api_ex.gitAlways pull from the internal GitLab and push to the combined remote:
# Pull and rebase from internal source
git pull origin master --rebase
# Push to both remotes simultaneously
git push all master --force-with-leaseWhen merging external contributions from GitHub:
# Fetch the PR branch (replace ID with PR number)
git fetch outbound pull/ID/head:pr-branch
# Rebase and merge locally
git checkout master
git rebase pr-branch
# Update both remotes
git push all master --force-with-lease
git branch -D pr-branchThe application uses a dedicated importer module to load STAC JSON data into PostgreSQL with PostGIS extension.
# Import from default directory (priv/stac_data)
mix stac.import
# Import from custom directory
mix stac.import /path/to/stac/data
The importer (StacApi.Data.Importer) performs the following:
-
Collections Import: Scans for
collection.jsonfiles using pattern**/collection.json -
Items Import: Processes all
*.jsonfiles exceptcollection.jsonandcatalog.json -
Data Validation: Only imports valid STAC Items (Features with geometry)
-
Conflict Resolution: Uses
on_conflict: :replace_allfor upserts
priv/stac_data/
├── collection1/
│ ├── collection.json
│ ├── item1.json
│ ├── item2.json
│ └── assets/
│ ├── item1.tif
│ └── item2.tif
└── collection2/
├── collection.json
└── items/
├── item3.json
└── item4.json
The import can be triggered:
-
CLI Task:
mix stac.import(shown above) -
Programmatically: Call
StacApi.Data.Importer.import_from_directory/1 -
Potential Web Interface: Could be extended to provide admin upload interface
-
Collections: Stores STAC Collection metadata
-
Items: Stores STAC Items with PostGIS geometry fields
-
Relationships: Items reference collections via
collection_id -
Timestamps: All timestamp columns are
timestamptz(6), and every schemausesStacApi.Schemarather thanEcto.Schema, which fixes the Ecto type (:utc_datetime_usec) in one place. This is what makes stored timestamps render as RFC 3339 with an offset; aNaiveDateTimewould silently render without one and fail STAC conformance. Note Ecto's:utc_datetime*types map to Postgrestimestamp, nottimestamptz— a migration has to saytimestamptzexplicitly. See PROJECT_DOCUMENTATION.md.
The browser interface (StacBrowserController) provides:
-
Directory Navigation: Browse STAC data structure
-
Search Interface: Query items with various filters
-
File Viewing: Display JSON content inline
The application uses PostgreSQL with PostGIS extension for spatial data:
# Database configuration includes PostGIS extension
config :stac_api, StacApi.Repo,
extensions: [{Geo.PostGIS.Extension, library: Geo}]Geo.ex provides Elixir structs for geometric data:
# Converting GeoJSON to PostGIS format
{:ok, geo_struct} = Geo.JSON.decode(geojson_geometry)
# Geo structs used in schema
field :geometry, Geo.PostGIS.GeometryThe Search module can be extended for spatial queries:
# Example spatial query (to be implemented)
from i in Item,
where: st_intersects(i.geometry, ^query_polygon)StacApi/
├── Data/ # Data layer
│ ├── Collection.ex # Collection schema
│ ├── Item.ex # Item schema
│ ├── Search.ex # Search logic
│ └── Importer.ex # Data import
├── Web/ # Web layer
│ ├── Controllers/
│ │ ├── RootController.ex
│ │ ├── SearchController.ex
│ │ ├── StacBrowserController.ex
│ │ └── CatalogJSON.ex / CollectionJSON.ex / ItemJSON.ex # Management API serializers
│ └── Router.ex
├── Schema.ex # Shared Ecto schema base (timestamp type)
└── Repo.ex # Database interface
Management API responses are built by one serializer module per type, so each STAC
representation is defined once instead of being rebuilt inline in every controller action.
The public STAC API keeps its own representations (CollectionsController.sanitize_collection/1,
Search.serialize_item_for_api/1) and does not share these.
For merging into an existing Phoenix application (e.g., geokuup.ee):
config :your_app, YourApp.Repo,
extensions: [{Geo.PostGIS.Extension, library: Geo}]# STAC API — public read (optional auth for private catalogs)
scope "/stac/api/v1", YourAppWeb do
pipe_through [:api, :read_auth]
get "/", RootController, :index
get "/conformance", RootController, :conformance
get "/search", SearchController, :index
post "/search", SearchController, :index
get "/collections", CollectionsController, :index
get "/collections/:id", CollectionsController, :show
get "/collections/:id/items", CollectionsController, :items
get "/collections/:collection_id/items/:item_id", CollectionsController, :show_item
end
# Management API — requires RW key
scope "/stac/manage/v1", YourAppWeb do
pipe_through [:api, :auth]
# catalogs, collections, items CRUD routes
end
scope "/stac/web", YourAppWeb do
pipe_through :browser
get "/browse", StacBrowserController, :index
get "/browse/*path", StacBrowserController, :show
end-
Copy
StacApi.Data.*modules to your app's data layer -
Adapt controllers to your app's naming convention
-
Update references to use your app's Repo module
Add to mix.exs:
{:geo, "~> 3.4"},
{:geo_postgis, "~> 3.4"},
{:jason, "~> 1.2"}-
Database: Run STAC table migrations in target app
-
Modules: Namespace modules under target app
-
Routes: Integrate routes into existing router
-
Configuration: Merge database and geo configurations
-
Assets: Ensure asset serving routes don't conflict
# config/runtime.exs
import Config
config :stac_api, :stac_data_path,
System.get_env("STAC_DATA_PATH") || "priv/stac_data"# config/dev.exs
config :stac_api, :stac_data_path, "priv/stac_data"
# config/prod.exs
config :stac_api, :stac_data_path, "/opt/app/stac_data"# Replace hardcoded paths with configuration
defmodule StacApiWeb.StacBrowserController do
@stac_data_path Application.compile_env(:stac_api, :stac_data_path, "priv/stac_data")
# Or for runtime configuration:
defp get_stac_data_path do
Application.get_env(:stac_api, :stac_data_path, "priv/stac_data")
end
end# .env or deployment configuration
export STAC_DATA_PATH="/mnt/stac-storage"
export DATABASE_URL="postgresql://user:pass@localhost/stac_prod"
# config/config.exs - Base configuration
config :stac_api,
stac_data_path: "priv/stac_data",
max_search_results: 10000,
default_page_size: 10
# config/runtime.exs - Runtime overrides
config :stac_api,
stac_data_path: System.get_env("STAC_DATA_PATH") || "priv/stac_data"GET /stac/api/v1/- STAC root catalogGET /stac/api/v1/conformance- OGC/STAC conformance classesGET /stac/api/v1/search- Search STAC items (GET + POST)GET /stac/api/v1/collections- List all collectionsGET /stac/api/v1/collections/:id- Get specific collectionGET /stac/api/v1/collections/:id/items- Get items in collectionGET /stac/api/v1/collections/:collection_id/items/:item_id- Get specific item
- Full CRUD for catalogs, collections, and items
- Bulk item import (
POST /stac/manage/v1/items/import)
Responses here carry two fields beyond the STAC ones:
created/updated— RFC 3339 UTC, top level on catalogs and collections, insidepropertieson items (STAC Common Metadata placement). Useful for detecting that a resource changed server-side before overwriting it.catalog_idon collections — the owning catalog, ornullfor root-level collections. Theparentlink deliberately points at the STAC root for every collection, so this is the only way to see which catalog a collection belongs to.
See PROJECT_DOCUMENTATION.md
for the caveats (server timestamps override client-supplied ones; GET omits nil fields).
Item temporal values are read from properties only — there is no top-level datetime
field. Either properties.datetime is a valid RFC 3339 UTC string, or it is null and
both properties.start_datetime and properties.end_datetime are given. Anything else
is a 400; nothing is silently coerced or dropped. Non-UTC offsets are accepted and
normalized to Z.
Temporal search matches by intersection with an item's footprint, so range items are
findable and an instant matches a range containing it. Full rules, including the
datetime=<start>/<end> and .. query syntax, are in
PROJECT_DOCUMENTATION.md.
Items created before this was enforced need a one-off backfill, or they stay invisible to
temporal search — mix stac.backfill_temporal in development, and in a release:
bin/stac_api eval 'StacApi.Release.backfill_temporal(["--dry-run"])'
bin/stac_api eval 'StacApi.Release.backfill_temporal()'See Deployment order.
GET /stac/web/browse- HTML directory browserGET /stac/web/browse/*path- Browse specific pathsGET /stac/web/search- HTML search interface
-
Setup: Configure database with PostGIS
-
Import Data: Use
mix stac.importto load JSON files -
Development: Use browser interface for testing
-
API Testing: Query REST endpoints for integration
-
Deployment: Configure production data paths and database