diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 61812ea6..b01579b8 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,6 +8,10 @@ updates:
directory: "/stac"
schedule:
interval: "weekly"
+ - package-ecosystem: "cargo"
+ directory: "/pgstac"
+ schedule:
+ interval: "weekly"
- package-ecosystem: "cargo"
directory: "/stac-api"
schedule:
@@ -20,6 +24,10 @@ updates:
directory: "/stac-cli"
schedule:
interval: "weekly"
+ - package-ecosystem: "cargo"
+ directory: "/stac-server"
+ schedule:
+ interval: "weekly"
- package-ecosystem: "cargo"
directory: "/stac-validate"
schedule:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c175f86a..d55f1221 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,10 @@ jobs:
- "-p stac -p stac-api -F geo"
- "-p stac-async"
- "-p stac-cli --no-default-features"
+ - "-p stac-server --no-default-features"
+ - "-p stac-server --no-default-features axum"
+ - "-p stac-server --no-default-features memory-item-search"
+ - "-p stac-server"
- "-p stac-validate"
steps:
- uses: actions/checkout@v4
@@ -115,3 +119,45 @@ jobs:
sudo apt-get install libgdal-dev
- name: Doc
run: cargo doc --all-features
+ validate-stac-server:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Rust cache
+ uses: Swatinem/rust-cache@v2
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ cache: "pip"
+ - name: Install stac-api-validator
+ run: pip install -r scripts/requirements.txt
+ - name: Validate
+ run: scripts/validate-stac-server
+ validate-stac-server-pgstac:
+ runs-on: ubuntu-latest
+ services:
+ pgstac:
+ image: ghcr.io/stac-utils/pgstac:v0.8.5
+ env:
+ POSTGRES_USER: username
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: postgis
+ PGUSER: username
+ PGPASSWORD: password
+ PGDATABASE: postgis
+ ports:
+ - 5432:5432
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Rust cache
+ uses: Swatinem/rust-cache@v2
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ cache: "pip"
+ - name: Install stac-api-validator
+ run: pip install -r scripts/requirements.txt
+ - name: Validate
+ run: scripts/validate-stac-server --pgstac
diff --git a/Cargo.toml b/Cargo.toml
index b65e50ee..ec37e9c3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
"stac-api",
"stac-async",
"stac-cli",
+ "stac-server",
"stac-validate",
]
default-members = [
@@ -13,5 +14,6 @@ default-members = [
"stac-api",
"stac-async",
"stac-cli",
+ "stac-server",
"stac-validate",
]
diff --git a/README.md b/README.md
index dea143c1..7a7567e3 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Command Line Interface (CLI) and Rust libraries for the [SpatioTemporal Asset Ca
- Use [stac-cli](./stac-cli/README.md) to query a STAC API, create and validate STAC items, and do other awesome stuff on the command line.
- Use the core [stac](./stac/README.md) library to incorporate STAC data structures (`Item`, `Catalog`, and `Collection`) in another Rust application.
- Use [stac-async](./stac-async/README.md) to build an application that uses async Rust via [tokio](https://tokio.rs/).
+- Use [stac-server](./stac-server/README.md) to serve a STAC API
## Crates
@@ -18,10 +19,11 @@ This monorepo contains several crates:
| ----- | ---- | --------- |
| [stac](./stac/README.md) | Core data structures and synchronous I/O | [![docs.rs](https://img.shields.io/docsrs/stac?style=flat-square)](https://docs.rs/stac/latest/stac/)
[![Crates.io](https://img.shields.io/crates/v/stac?style=flat-square)](https://crates.io/crates/stac) |
| [pgstac](./pgstac/README.md) | Bindings for [pgstac](https://github.com/stac-utils/pgstac) | [![docs.rs](https://img.shields.io/docsrs/pgstac?style=flat-square)](https://docs.rs/pgstac/latest/pgstac/)
[![Crates.io](https://img.shields.io/crates/v/pgstac?style=flat-square)](https://crates.io/crates/pgstac) |
-| [stac-validate](./stac-validate/README.md) | Validate STAC data structures with [jsonschema](https://json-schema.org/) | [![docs.rs](https://img.shields.io/docsrs/stac-validate?style=flat-square)](https://docs.rs/stac-validate/latest/stac-validate/)
[![Crates.io](https://img.shields.io/crates/v/stac-validate?style=flat-square)](https://crates.io/crates/stac-validate) |
| [stac-api](./stac-api/README.md) | Data structures for the [STAC API](https://github.com/radiantearth/stac-api-spec) specification | [![docs.rs](https://img.shields.io/docsrs/stac-api?style=flat-square)](https://docs.rs/stac-api/latest/stac_api/)
[![Crates.io](https://img.shields.io/crates/v/stac-api?style=flat-square)](https://crates.io/crates/stac-api) |
| [stac-async](./stac-async/README.md) | Asynchronous I/O with [tokio](https://tokio.rs/) | [![docs.rs](https://img.shields.io/docsrs/stac-async?style=flat-square)](https://docs.rs/stac-async/latest/stac_async/)
[![Crates.io](https://img.shields.io/crates/v/stac-async?style=flat-square)](https://crates.io/crates/stac-async) |
| [stac-cli](./stac-cli/README.md)| Command line interface | [![docs.rs](https://img.shields.io/docsrs/stac-cli?style=flat-square)](https://docs.rs/stac-cli/latest/stac_cli/)
[![Crates.io](https://img.shields.io/crates/v/stac-cli?style=flat-square)](https://crates.io/crates/stac-cli) |
+| [stac-server](./stac-server/README.md)| STAC API server with multiple backends | [![docs.rs](https://img.shields.io/docsrs/stac-server?style=flat-square)](https://docs.rs/stac-server/latest/stac_server/)
[![Crates.io](https://img.shields.io/crates/v/stac-server?style=flat-square)](https://crates.io/crates/stac-server) |
+| [stac-validate](./stac-validate/README.md) | Validate STAC data structures with [jsonschema](https://json-schema.org/) | [![docs.rs](https://img.shields.io/docsrs/stac-validate?style=flat-square)](https://docs.rs/stac-validate/latest/stac-validate/)
[![Crates.io](https://img.shields.io/crates/v/stac-validate?style=flat-square)](https://crates.io/crates/stac-validate) |
## Development
diff --git a/scripts/requirements.in b/scripts/requirements.in
new file mode 100644
index 00000000..e5e79181
--- /dev/null
+++ b/scripts/requirements.in
@@ -0,0 +1 @@
+stac-api-validator @ git+https://github.com/stac-utils/stac-api-validator@24dd3f27174e5c85f28a44c84ad584a2d344e6cc
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
new file mode 100644
index 00000000..71a8f3e2
--- /dev/null
+++ b/scripts/requirements.txt
@@ -0,0 +1,80 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile scripts/requirements.in
+attrs==23.2.0
+ # via
+ # jsonschema
+ # referencing
+certifi==2024.2.2
+ # via
+ # requests
+ # stac-api-validator
+charset-normalizer==3.3.2
+ # via requests
+click==8.1.7
+ # via
+ # stac-api-validator
+ # stac-check
+ # stac-validator
+deepdiff==6.7.1
+ # via stac-api-validator
+idna==3.7
+ # via requests
+jsonschema==4.21.1
+ # via
+ # pystac
+ # stac-api-validator
+ # stac-check
+ # stac-validator
+jsonschema-specifications==2023.12.1
+ # via jsonschema
+more-itertools==8.14.0
+ # via stac-api-validator
+numpy==1.26.4
+ # via shapely
+ordered-set==4.1.0
+ # via deepdiff
+orjson==3.10.1
+ # via pystac
+pystac==1.10.0
+ # via
+ # pystac-client
+ # stac-api-validator
+pystac-client==0.7.7
+ # via stac-api-validator
+python-dateutil==2.9.0.post0
+ # via
+ # pystac
+ # pystac-client
+python-dotenv==1.0.1
+ # via stac-check
+pyyaml==6.0.1
+ # via
+ # stac-api-validator
+ # stac-check
+referencing==0.35.0
+ # via
+ # jsonschema
+ # jsonschema-specifications
+requests==2.31.0
+ # via
+ # pystac-client
+ # stac-api-validator
+ # stac-check
+ # stac-validator
+rpds-py==0.18.0
+ # via
+ # jsonschema
+ # referencing
+shapely==2.0.4
+ # via stac-api-validator
+six==1.16.0
+ # via python-dateutil
+stac-api-validator @ git+https://github.com/stac-utils/stac-api-validator@24dd3f27174e5c85f28a44c84ad584a2d344e6cc
+stac-check==1.3.3
+ # via stac-api-validator
+stac-validator==3.3.2
+ # via
+ # stac-api-validator
+ # stac-check
+urllib3==2.2.1
+ # via requests
diff --git a/scripts/validate-stac-server b/scripts/validate-stac-server
new file mode 100755
index 00000000..71424e14
--- /dev/null
+++ b/scripts/validate-stac-server
@@ -0,0 +1,34 @@
+#!/usr/bin/env sh
+
+set -e
+
+args="stac-server/data/sentinel-2/*"
+build_args="--no-default-features"
+
+if [ $# -eq 1 ]; then
+ if [ "$1" = "--pgstac" ]; then
+ args="$args --pgstac postgres://username:password@localhost/postgis"
+ build_args="$build_args -F pgstac"
+ else
+ echo "Unknown argument: $1"
+ exit 1
+ fi
+fi
+
+cargo build -p stac-cli $build_args
+cargo run $build_args -- serve $args &
+server_pid=$!
+echo "server_pid=$server_pid"
+set +e
+scripts/wait-for-it.sh localhost:7822 && \
+ stac-api-validator \
+ --root-url http://localhost:7822 \
+ --conformance core \
+ --conformance features \
+ --conformance item-search \
+ --collection sentinel-2-c1-l2a \
+ --geometry '{"type":"Point","coordinates":[-105.07,40.08]}'
+status=$?
+set -e
+kill $server_pid
+exit $status
diff --git a/scripts/wait-for-it.sh b/scripts/wait-for-it.sh
new file mode 100755
index 00000000..8fa2a7d6
--- /dev/null
+++ b/scripts/wait-for-it.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# Use this script to test if a given TCP host/port are available
+# https://github.com/vishnubob/wait-for-it/blob/81b1373f17855a4dc21156cfe1694c31d7d1792e/wait-for-it.sh
+
+WAITFORIT_cmdname=${0##*/}
+
+echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
+
+usage()
+{
+ cat << USAGE >&2
+Usage:
+ $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
+ -h HOST | --host=HOST Host or IP under test
+ -p PORT | --port=PORT TCP port under test
+ Alternatively, you specify the host and port as host:port
+ -s | --strict Only execute subcommand if the test succeeds
+ -q | --quiet Don't output any status messages
+ -t TIMEOUT | --timeout=TIMEOUT
+ Timeout in seconds, zero for no timeout
+ -- COMMAND ARGS Execute command with args after the test finishes
+USAGE
+ exit 1
+}
+
+wait_for()
+{
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ else
+ echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
+ fi
+ WAITFORIT_start_ts=$(date +%s)
+ while :
+ do
+ if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
+ nc -z $WAITFORIT_HOST $WAITFORIT_PORT
+ WAITFORIT_result=$?
+ else
+ (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
+ WAITFORIT_result=$?
+ fi
+ if [[ $WAITFORIT_result -eq 0 ]]; then
+ WAITFORIT_end_ts=$(date +%s)
+ echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
+ break
+ fi
+ sleep 1
+ done
+ return $WAITFORIT_result
+}
+
+wait_for_wrapper()
+{
+ # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
+ if [[ $WAITFORIT_QUIET -eq 1 ]]; then
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ else
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ fi
+ WAITFORIT_PID=$!
+ trap "kill -INT -$WAITFORIT_PID" INT
+ wait $WAITFORIT_PID
+ WAITFORIT_RESULT=$?
+ if [[ $WAITFORIT_RESULT -ne 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ fi
+ return $WAITFORIT_RESULT
+}
+
+# process arguments
+while [[ $# -gt 0 ]]
+do
+ case "$1" in
+ *:* )
+ WAITFORIT_hostport=(${1//:/ })
+ WAITFORIT_HOST=${WAITFORIT_hostport[0]}
+ WAITFORIT_PORT=${WAITFORIT_hostport[1]}
+ shift 1
+ ;;
+ --child)
+ WAITFORIT_CHILD=1
+ shift 1
+ ;;
+ -q | --quiet)
+ WAITFORIT_QUIET=1
+ shift 1
+ ;;
+ -s | --strict)
+ WAITFORIT_STRICT=1
+ shift 1
+ ;;
+ -h)
+ WAITFORIT_HOST="$2"
+ if [[ $WAITFORIT_HOST == "" ]]; then break; fi
+ shift 2
+ ;;
+ --host=*)
+ WAITFORIT_HOST="${1#*=}"
+ shift 1
+ ;;
+ -p)
+ WAITFORIT_PORT="$2"
+ if [[ $WAITFORIT_PORT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --port=*)
+ WAITFORIT_PORT="${1#*=}"
+ shift 1
+ ;;
+ -t)
+ WAITFORIT_TIMEOUT="$2"
+ if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --timeout=*)
+ WAITFORIT_TIMEOUT="${1#*=}"
+ shift 1
+ ;;
+ --)
+ shift
+ WAITFORIT_CLI=("$@")
+ break
+ ;;
+ --help)
+ usage
+ ;;
+ *)
+ echoerr "Unknown argument: $1"
+ usage
+ ;;
+ esac
+done
+
+if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
+ echoerr "Error: you need to provide a host and port to test."
+ usage
+fi
+
+WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
+WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
+WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
+WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
+
+# Check to see if timeout is from busybox?
+WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
+WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
+
+WAITFORIT_BUSYTIMEFLAG=""
+if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
+ WAITFORIT_ISBUSY=1
+ # Check if busybox timeout uses -t flag
+ # (recent Alpine versions don't support -t anymore)
+ if timeout &>/dev/stdout | grep -q -e '-t '; then
+ WAITFORIT_BUSYTIMEFLAG="-t"
+ fi
+else
+ WAITFORIT_ISBUSY=0
+fi
+
+if [[ $WAITFORIT_CHILD -gt 0 ]]; then
+ wait_for
+ WAITFORIT_RESULT=$?
+ exit $WAITFORIT_RESULT
+else
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ wait_for_wrapper
+ WAITFORIT_RESULT=$?
+ else
+ wait_for
+ WAITFORIT_RESULT=$?
+ fi
+fi
+
+if [[ $WAITFORIT_CLI != "" ]]; then
+ if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
+ echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
+ exit $WAITFORIT_RESULT
+ fi
+ exec "${WAITFORIT_CLI[@]}"
+else
+ exit $WAITFORIT_RESULT
+fi
+
diff --git a/stac-api/CHANGELOG.md b/stac-api/CHANGELOG.md
index 829559f3..13c6a7c6 100644
--- a/stac-api/CHANGELOG.md
+++ b/stac-api/CHANGELOG.md
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- `Conformance` builder functions ([#247](https://github.com/stac-utils/stac-rs/pull/247))
- Un-serialized pagination members to `ItemCollection` ([#247](https://github.com/stac-utils/stac-rs/pull/247))
- `stac::Fields` for `Search` and `Items` ([#247](https://github.com/stac-utils/stac-rs/pull/247))
+- `Items::valid` and `Search::valid` ([#244](https://github.com/stac-utils/stac-rs/pull/244))
### Changed
@@ -19,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Removed
- `schemars` feature ([#245](https://github.com/stac-utils/stac-rs/pull/245))
+- `Search::validate` ([#244](https://github.com/stac-utils/stac-rs/pull/244))
## [0.3.3] - 2024-04-07
diff --git a/stac-api/Cargo.toml b/stac-api/Cargo.toml
index 6703f10a..3125e5bc 100644
--- a/stac-api/Cargo.toml
+++ b/stac-api/Cargo.toml
@@ -14,6 +14,7 @@ categories = ["science", "data-structures", "web-programming"]
geo = ["dep:geo", "stac/geo"]
[dependencies]
+chrono = "0.4"
geo = { version = "0.28", optional = true }
geojson = "0.24"
serde = "1"
diff --git a/stac-api/src/error.rs b/stac-api/src/error.rs
index 0b3e903a..affcf159 100644
--- a/stac-api/src/error.rs
+++ b/stac-api/src/error.rs
@@ -1,4 +1,5 @@
use crate::Search;
+use chrono::{DateTime, FixedOffset};
use serde_json::{Map, Value};
use thiserror::Error;
@@ -16,14 +17,26 @@ pub enum Error {
#[error("cannot convert cql2-json to strings")]
CannotConvertCql2JsonToString(Map),
+ /// [chrono::ParseError]
+ #[error(transparent)]
+ ChronoParse(#[from] chrono::ParseError),
+
/// [geojson::Error]
#[error(transparent)]
GeoJson(#[from] geojson::Error),
+ /// An empty datetime interval.
+ #[error("empty datetime interval")]
+ EmptyDatetimeInterval,
+
/// Some functionality requires a certain optional feature to be enabled.
#[error("feature not enabled: {0}")]
FeatureNotEnabled(&'static str),
+ /// Invalid bounding box.
+ #[error("invalid bbox ({0:?}): {1}")]
+ InvalidBbox(Vec, &'static str),
+
/// [std::num::ParseIntError]
#[error(transparent)]
ParseIntError(#[from] std::num::ParseIntError),
@@ -48,6 +61,10 @@ pub enum Error {
#[error(transparent)]
Stac(#[from] stac::Error),
+ /// The start time is after the end time.
+ #[error("start ({0}) is after end ({1})")]
+ StartIsAfterEnd(DateTime, DateTime),
+
/// [std::num::TryFromIntError]
#[error(transparent)]
TryFromInt(#[from] std::num::TryFromIntError),
diff --git a/stac-api/src/items.rs b/stac-api/src/items.rs
index a7d81c90..08369d73 100644
--- a/stac-api/src/items.rs
+++ b/stac-api/src/items.rs
@@ -1,4 +1,5 @@
use crate::{Error, Fields, Filter, Result, Search, Sortby};
+use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use stac::Item;
@@ -100,6 +101,62 @@ pub struct GetItems {
}
impl Items {
+ /// Runs a set of validity checks on this query and returns an error if it is invalid.
+ ///
+ /// Returns the items, unchanged, if it is valid.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use stac_api::Items;
+ ///
+ /// let items = Items::default().valid().unwrap();
+ /// ```
+ pub fn valid(self) -> Result {
+ if let Some(bbox) = self.bbox.as_ref() {
+ if bbox.len() == 4 {
+ if bbox[1] > bbox[3] {
+ return Err(Error::InvalidBbox(
+ bbox.clone(),
+ "min latitude is greater than max latitude",
+ ));
+ }
+ } else if bbox.len() == 6 {
+ if bbox[1] > bbox[4] {
+ return Err(Error::InvalidBbox(
+ bbox.clone(),
+ "min latitude is greater than max latitude",
+ ));
+ }
+ } else {
+ return Err(Error::InvalidBbox(
+ bbox.clone(),
+ "invalid number of coordinates",
+ ));
+ }
+ }
+ if let Some(datetime) = self.datetime.as_deref() {
+ if let Some((start, end)) = datetime.split_once('/') {
+ let (start, end) = (
+ maybe_parse_from_rfc3339(start)?,
+ maybe_parse_from_rfc3339(end)?,
+ );
+ if let Some(start) = start {
+ if let Some(end) = end {
+ if end < start {
+ return Err(Error::StartIsAfterEnd(start, end));
+ }
+ }
+ } else if end.is_none() {
+ return Err(Error::EmptyDatetimeInterval);
+ }
+ } else {
+ let _ = maybe_parse_from_rfc3339(datetime)?;
+ }
+ }
+ Ok(self)
+ }
+
/// Returns true if this items structure matches the given item.
///
/// # Examples
@@ -350,6 +407,16 @@ impl stac::Fields for Items {
}
}
+fn maybe_parse_from_rfc3339(s: &str) -> Result