Repository: stac-utils/stac-rs (language: python)

Issue:
Support pgstac v0.8 and v0.9
Looks like v0.9 breaks stuff as it is, so we should support both ideally.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 52b42334..6411b4fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -102,9 +102,14 @@ jobs:
   test-pgstac:
     name: Test pgstac
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        pgstac_version:
+          - v0.8.6
+          - v0.9.1
     services:
       pgstac:
-        image: ghcr.io/stac-utils/pgstac:v0.8.6
+        image: ghcr.io/stac-utils/pgstac:${{ matrix.pgstac_version }}
         env:
           POSTGRES_USER: username
           POSTGRES_PASSWORD: password
diff --git a/crates/pgstac/Cargo.toml b/crates/pgstac/Cargo.toml
index 7ab341a4..a5fab663 100644
--- a/crates/pgstac/Cargo.toml
+++ b/crates/pgstac/Cargo.toml
@@ -18,6 +18,7 @@ tls = ["dep:rustls", "dep:tokio-postgres-rustls", "dep:webpki-roots"]
 rustls = { workspace = true, features = ["ring", "std"], optional = true }
 serde.workspace = true
 serde_json.workspace = true
+stac.workspace = true
 stac-api.workspace = true
 thiserror.workspace = true
 tokio-postgres = { workspace = true, features = ["with-serde_json-1"] }
@@ -26,8 +27,7 @@ webpki-roots = { workspace = true, optional = true }
 
 [dev-dependencies]
 geojson.workspace = true
-stac.workspace = true
-pgstac-test = { path = "pgstac-test" }
+rstest.workspace = true
 tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
 tokio-test.workspace = true
 
diff --git a/crates/pgstac/src/lib.rs b/crates/pgstac/src/lib.rs
index b6ba2d1f..fd584ab9 100644
--- a/crates/pgstac/src/lib.rs
+++ b/crates/pgstac/src/lib.rs
@@ -313,43 +313,133 @@ impl<T> Pgstac for T where T: GenericClient {}
 pub(crate) mod tests {
     use super::Pgstac;
     use geojson::{Geometry, Value};
-    use pgstac_test::pgstac_test;
+    use rstest::{fixture, rstest};
     use serde_json::{json, Map};
     use stac::{Collection, Item};
     use stac_api::{Fields, Filter, Search, Sortby};
-    use std::sync::Mutex;
-    use tokio_postgres::Transaction;
+    use std::{
+        ops::Deref,
+        sync::{atomic::AtomicU16, Mutex},
+    };
+    use tokio_postgres::{Client, Config, NoTls};
     use tokio_test as _;
 
-    // This is an absolutely heinous way to ensure that only one test is hitting
-    // the DB at a time -- the MUTEX is used in the pgstac-test crate as part of
-    // the code generated by `pgstac_test`.
-    //
-    // There's got to be a better way.
-    pub(crate) static MUTEX: Mutex<()> = Mutex::new(());
+    static MUTEX: Mutex<()> = Mutex::new(());
+
+    struct TestClient {
+        client: Client,
+        config: Config,
+        dbname: String,
+    }
+
+    impl TestClient {
+        async fn new(id: u16) -> TestClient {
+            let dbname = format!("pgstac_test_{}", id);
+            let config: Config = std::env::var("PGSTAC_RS_TEST_DB")
+                .unwrap_or("postgresql://username:password@localhost:5432/postgis".to_string())
+                .parse()
+                .unwrap();
+            {
+                let _mutex = MUTEX.lock().unwrap();
+                let (client, connection) = config.connect(NoTls).await.unwrap();
+                let _handle = tokio::spawn(async move { connection.await.unwrap() });
+                let _ = client
+                    .execute(
+                        &format!(
+                            "CREATE DATABASE {} TEMPLATE {}",
+                            dbname,
+                            config.get_dbname().unwrap()
+                        ),
+                        &[],
+                    )
+                    .await
+                    .unwrap();
+            }
+            let mut test_config = config.clone();
+            let (client, connection) = test_config.dbname(&dbname).connect(NoTls).await.unwrap();
+            let _handle = tokio::spawn(async move { connection.await.unwrap() });
+            TestClient {
+                client,
+                config,
+                dbname,
+            }
+        }
+
+        async fn terminate(&mut self) {
+            let (client, connection) = self.config.connect(NoTls).await.unwrap();
+            let _handle = tokio::spawn(async move { connection.await.unwrap() });
+            let _ = client
+                .execute(
+                    "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1",
+                    &[&self.dbname],
+                )
+                .await
+                .unwrap();
+            let _ = client
+                .execute(&format!("DROP DATABASE {}", self.dbname), &[])
+                .await
+                .unwrap();
+        }
+    }
+
+    impl Drop for TestClient {
+        fn drop(&mut self) {
+            std::thread::scope(|s| {
+                let _ = s.spawn(|| {
+                    let runtime = tokio::runtime::Builder::new_multi_thread()
+                        .enable_all()
+                        .build()
+                        .unwrap();
+                    runtime.block_on(self.terminate());
+                });
+            });
+        }
+    }
+
+    impl Deref for TestClient {
+        type Target = Client;
+        fn deref(&self) -> &Self::Target {
+            &self.client
+        }
+    }
 
     fn longmont() -> Geometry {
         Geometry::new(Value::Point(vec![-105.1019, 40.1672]))
     }
 
-    #[pgstac_test]
-    async fn pgstac_version(client: &Transaction<'_>) {
+    #[fixture]
+    fn id() -> u16 {
+        static COUNTER: AtomicU16 = AtomicU16::new(0);
+        COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+    }
+
+    #[fixture]
+    async fn client(id: u16) -> TestClient {
+        TestClient::new(id).await
+    }
+
+    #[rstest]
+    #[tokio::test]
+    async fn pgstac_version(#[future(awt)] client: TestClient) {
         let _ = client.pgstac_version().await.unwrap();
     }
 
-    #[pgstac_test]
-    async fn context(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn context(#[future(awt)] client: TestClient) {
         assert!(!client.context().await.unwrap());
     }
 
-    #[pgstac_test]
-    async fn set_context(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn set_context(#[future(awt)] client: TestClient) {
         client.set_context(true).await.unwrap();
         assert!(client.context().await.unwrap());
     }
 
-    #[pgstac_test]
-    async fn collections(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn collections(#[future(awt)] client: TestClient) {
         assert!(client.collections().await.unwrap().is_empty());
         client
             .add_collection(Collection::new("an-id", "a description"))
@@ -358,16 +448,18 @@ pub(crate) mod tests {
         assert_eq!(client.collections().await.unwrap().len(), 1);
     }
 
-    #[pgstac_test]
-    async fn add_collection_duplicate(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn add_collection_duplicate(#[future(awt)] client: TestClient) {
         assert!(client.collections().await.unwrap().is_empty());
         let collection = Collection::new("an-id", "a description");
         client.add_collection(collection.clone()).await.unwrap();
         assert!(client.add_collection(collection).await.is_err());
     }
 
-    #[pgstac_test]
-    async fn upsert_collection(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn upsert_collection(#[future(awt)] client: TestClient) {
         assert!(client.collections().await.unwrap().is_empty());
         let mut collection = Collection::new("an-id", "a description");
         client.upsert_collection(collection.clone()).await.unwrap();
@@ -379,8 +471,9 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn update_collection(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn update_collection(#[future(awt)] client: TestClient) {
         let mut collection = Collection::new("an-id", "a description");
         client.add_collection(collection.clone()).await.unwrap();
         assert!(client
@@ -399,19 +492,22 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn update_collection_does_not_exit(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn update_collection_does_not_exit(#[future(awt)] client: TestClient) {
         let collection = Collection::new("an-id", "a description");
         assert!(client.update_collection(collection).await.is_err());
     }
 
-    #[pgstac_test]
-    async fn collection_not_found(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn collection_not_found(#[future(awt)] client: TestClient) {
         assert!(client.collection("not-an-id").await.unwrap().is_none());
     }
 
-    #[pgstac_test]
-    async fn delete_collection(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn delete_collection(#[future(awt)] client: TestClient) {
         let collection = Collection::new("an-id", "a description");
         client.add_collection(collection.clone()).await.unwrap();
         assert!(client.collection("an-id").await.unwrap().is_some());
@@ -419,13 +515,15 @@ pub(crate) mod tests {
         assert!(client.collection("an-id").await.unwrap().is_none());
     }
 
-    #[pgstac_test]
-    async fn delete_collection_does_not_exist(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn delete_collection_does_not_exist(#[future(awt)] client: TestClient) {
         assert!(client.delete_collection("not-an-id").await.is_err());
     }
 
-    #[pgstac_test]
-    async fn item(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn item(#[future(awt)] client: TestClient) {
         assert!(client
             .item("an-id", Some("collection-id"))
             .await
@@ -450,14 +548,16 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn item_without_collection(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn item_without_collection(#[future(awt)] client: TestClient) {
         let item = Item::new("an-id");
         assert!(client.add_item(item.clone()).await.is_err());
     }
 
-    #[pgstac_test]
-    async fn update_item(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn update_item(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -479,8 +579,9 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn delete_item(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn delete_item(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -497,8 +598,9 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn upsert_item(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn upsert_item(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -508,8 +610,9 @@ pub(crate) mod tests {
         client.upsert_item(item).await.unwrap();
     }
 
-    #[pgstac_test]
-    async fn add_items(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn add_items(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -530,8 +633,9 @@ pub(crate) mod tests {
             .is_some());
     }
 
-    #[pgstac_test]
-    async fn upsert_items(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn upsert_items(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -544,8 +648,9 @@ pub(crate) mod tests {
         client.upsert_items(&items).await.unwrap();
     }
 
-    #[pgstac_test]
-    async fn search_everything(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_everything(#[future(awt)] client: TestClient) {
         assert!(client
             .search(Search::default())
             .await
@@ -564,8 +669,9 @@ pub(crate) mod tests {
         );
     }
 
-    #[pgstac_test]
-    async fn search_ids(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_ids(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -584,8 +690,9 @@ pub(crate) mod tests {
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
-    #[pgstac_test]
-    async fn search_collections(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_collections(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -604,8 +711,9 @@ pub(crate) mod tests {
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
-    #[pgstac_test]
-    async fn search_limit(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_limit(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -618,11 +726,18 @@ pub(crate) mod tests {
         search.items.limit = Some(1);
         let page = client.search(search).await.unwrap();
         assert_eq!(page.features.len(), 1);
-        assert_eq!(page.context.limit.unwrap(), 1);
+        if let Some(context) = page.context {
+            // v0.8
+            assert_eq!(context.limit.unwrap(), 1);
+        } else {
+            // v0.9
+            assert_eq!(page.number_returned.unwrap(), 1);
+        }
     }
 
-    #[pgstac_test]
-    async fn search_bbox(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_bbox(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -639,8 +754,9 @@ pub(crate) mod tests {
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
-    #[pgstac_test]
-    async fn search_datetime(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_datetime(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -658,8 +774,9 @@ pub(crate) mod tests {
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
-    #[pgstac_test]
-    async fn search_intersects(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn search_intersects(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -702,8 +819,9 @@ pub(crate) mod tests {
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
-    #[pgstac_test]
-    async fn pagination(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn pagination(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -720,18 +838,19 @@ pub(crate) mod tests {
         assert_eq!(page.features[0]["id"], "an-id");
         let _ = search
             .additional_fields
-            .insert("token".to_string(), page.next_token().into());
+            .insert("token".to_string(), "next:collection-id:an-id".into());
         let page = client.search(search.clone()).await.unwrap();
         assert_eq!(page.features[0]["id"], "another-id");
         let _ = search
             .additional_fields
-            .insert("token".to_string(), page.prev_token().into());
+            .insert("token".to_string(), "prev:collection-id:another-id".into());
         let page = client.search(search).await.unwrap();
         assert_eq!(page.features[0]["id"], "an-id");
     }
 
-    #[pgstac_test]
-    async fn fields(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn fields(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("an-id");
@@ -757,8 +876,9 @@ pub(crate) mod tests {
         assert!(item["properties"].as_object().unwrap().get("bar").is_none());
     }
 
-    #[pgstac_test]
-    async fn sortby(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn sortby(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("a");
@@ -779,8 +899,9 @@ pub(crate) mod tests {
         assert_eq!(page.features[1]["id"], "a");
     }
 
-    #[pgstac_test]
-    async fn filter(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn filter(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("a");
@@ -806,8 +927,9 @@ pub(crate) mod tests {
         assert_eq!(page.features.len(), 1);
     }
 
-    #[pgstac_test]
-    async fn query(client: &Transaction<'_>) {
+    #[rstest]
+    #[tokio::test]
+    async fn query(#[future(awt)] client: TestClient) {
         let collection = Collection::new("collection-id", "a description");
         client.add_collection(collection).await.unwrap();
         let mut item = Item::new("a");
diff --git a/crates/pgstac/src/page.rs b/crates/pgstac/src/page.rs
index 88f495f4..ca470695 100644
--- a/crates/pgstac/src/page.rs
+++ b/crates/pgstac/src/page.rs
@@ -1,4 +1,6 @@
 use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+use stac::Link;
 use stac_api::{Context, Item};
 
 /// A page of search results.
@@ -15,7 +17,26 @@ pub struct Page {
     pub prev: Option<String>,
 
     /// The search context.
-    pub context: Context,
+    ///
+    /// This was removed in pgstac v0.9
+    #[serde(default)]
+    pub context: Option<Context>,
+
+    /// The number of values returned.
+    ///
+    /// Added in pgstac v0.9
+    #[serde(rename = "numberReturned")]
+    pub number_returned: Option<usize>,
+
+    /// Links
+    ///
+    /// Added in pgstac v0.9
+    #[serde(default)]
+    pub links: Vec<Link>,
+
+    /// Additional fields.
+    #[serde(flatten)]
+    pub additional_fields: Map<String, Value>,
 }
 
 impl Page {
diff --git a/crates/pgstac/src/tls.rs b/crates/pgstac/src/tls.rs
index 99123e48..dbf05814 100644
--- a/crates/pgstac/src/tls.rs
+++ b/crates/pgstac/src/tls.rs
@@ -96,14 +96,12 @@ impl Default for DummyTlsVerifier {
 
 #[cfg(test)]
 mod tests {
-    use crate::tests::MUTEX;
 
     #[tokio::test]
     async fn connect() {
-        let _mutex = MUTEX.lock().unwrap();
-        let config = std::env::var("PGSTAC_RS_TEST_DB")
-            .unwrap_or("postgresql://username:password@localhost:5432/postgis".to_string());
         let tls = super::make_unverified_tls();
-        let (_, _) = tokio_postgres::connect(&config, tls).await.unwrap();
+        let (_, _) = tokio_postgres::connect("host=/var/run/postgresql", tls)
+            .await
+            .unwrap();
     }
 }
diff --git a/crates/server/src/backend/pgstac.rs b/crates/server/src/backend/pgstac.rs
index 9acb78de..f872f6d9 100644
--- a/crates/server/src/backend/pgstac.rs
+++ b/crates/server/src/backend/pgstac.rs
@@ -158,7 +158,7 @@ where
             let _ = prev.insert("token".into(), prev_token.into());
             item_collection.prev = Some(prev);
         }
-        item_collection.context = Some(page.context);
+        item_collection.context = page.context;
         Ok(item_collection)
     }
 }
diff --git a/docker-compose.yml b/docker-compose.yml
index 4bf33251..6fdddb39 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
 services:
   pgstac:
     container_name: stac-rs
-    image: ghcr.io/stac-utils/pgstac:v0.8.6
+    image: ghcr.io/stac-utils/pgstac:${PGSTAC_VERSION:-v0.9.1}
     environment:
       - POSTGRES_USER=username
       - POSTGRES_PASSWORD=password


Test patch:
diff --git a/crates/pgstac/pgstac-test/.gitignore b/crates/pgstac/pgstac-test/.gitignore
deleted file mode 100644
index ea8c4bf7..00000000
--- a/crates/pgstac/pgstac-test/.gitignore
+++ /dev/null
@@ -1,1 +0,0 @@
-/target
diff --git a/crates/pgstac/pgstac-test/Cargo.toml b/crates/pgstac/pgstac-test/Cargo.toml
deleted file mode 100644
index a1e8f627..00000000
--- a/crates/pgstac/pgstac-test/Cargo.toml
+++ /dev/null
@@ -1,15 +0,0 @@
-[package]
-name = "pgstac-test"
-version = "0.0.0"
-edition.workspace = true
-publish = false
-
-[lib]
-proc-macro = true
-test = false
-doctest = false
-
-[dependencies]
-quote.workspace = true
-syn = { workspace = true, features = ["full", "extra-traits"] }
-tokio-postgres.workspace = true
diff --git a/crates/pgstac/pgstac-test/src/lib.rs b/crates/pgstac/pgstac-test/src/lib.rs
deleted file mode 100644
index 5ae0c547..00000000
--- a/crates/pgstac/pgstac-test/src/lib.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-use proc_macro::TokenStream;
-use quote::quote;
-use syn::ItemFn;
-
-#[proc_macro_attribute]
-pub fn pgstac_test(_args: TokenStream, input: TokenStream) -> TokenStream {
-    let ast = syn::parse(input).unwrap();
-    impl_pgstac_test(ast)
-}
-
-fn impl_pgstac_test(ast: ItemFn) -> TokenStream {
-    let ident = &ast.sig.ident;
-    let gen = quote! {
-        #[tokio::test]
-        async fn #ident() {
-            let _mutex = MUTEX.lock().unwrap();
-            let config = std::env::var("PGSTAC_RS_TEST_DB")
-                .unwrap_or("postgresql://username:password@localhost:5432/postgis".to_string());
-            let (mut client, connection) = tokio_postgres::connect(&config, tokio_postgres::NoTls).await.unwrap();
-            tokio::spawn(async move {
-                connection.await.unwrap()
-            });
-            let transaction = client.transaction().await.unwrap();
-            #ast
-            #ident(&transaction).await;
-            transaction.rollback().await.unwrap();
-        }
-    };
-    gen.into()
-}