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

Issue:
Make stac-validate async
The validator fetches schema w/ blocking, so we might need to re-implement to do it async?
Update to v1.1.0 of the core spec
https://github.com/radiantearth/stac-spec/releases/tag/v1.1.0

Off the top of my head, we'll want to:

- [x] Update the default STAC version
- [x] Add the new mime types from beta.1
- [x] Add `item_assets` to `Collection`
- [x] Add migration to v1.1.0
- [x] Add the examples and switch all the symlinks to point to v1.1.0
- [x] Add the schemas to **stac-validate**
- [x] ~Create a `Reader` (or a builder for a reader) that allows us to enable or diable auto-migrating on read~ Juice not worth the squeeze at this time, IMO.
- [x] Re-enable read tests using **reqwests** (they currently are ignored b/c they can't read v1.1.0)

There could be more, too.


Discussion / hints:

Fix patch:
diff --git a/cli/src/args/validate.rs b/cli/src/args/validate.rs
index 6b426232..17699c90 100644
--- a/cli/src/args/validate.rs
+++ b/cli/src/args/validate.rs
@@ -16,15 +16,8 @@ pub(crate) struct Args {
 impl Run for Args {
     async fn run(self, input: Input, _: Option<Sender<Value>>) -> Result<Option<Value>> {
         let value = input.get().await?;
-        let result = tokio::task::spawn_blocking(move || {
-            if let Err(err) = value.validate() {
-                Err((err, value))
-            } else {
-                Ok(())
-            }
-        })
-        .await?;
-        if let Err((stac_validate::Error::Validation(ref errors), ref value)) = result {
+        let result = value.validate().await;
+        if let Err(stac_validate::Error::Validation(ref errors)) = result {
             let message_base = match value {
                 stac::Value::Item(item) => format!("[item={}] ", item.id),
                 stac::Value::Catalog(catalog) => format!("[catalog={}] ", catalog.id),
@@ -38,7 +31,7 @@ impl Run for Args {
                 );
             }
         }
-        result.and(Ok(None)).map_err(|(err, _)| Error::from(err))
+        result.and(Ok(None)).map_err(Error::from)
     }
 
     fn take_infile(&mut self) -> Option<String> {
diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md
index d3530fab..d9169b76 100644
--- a/core/CHANGELOG.md
+++ b/core/CHANGELOG.md
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 - Conversion traits for the three formats ([#396](https://github.com/stac-utils/stac-rs/pull/396))
 - `object_store` ([#382](https://github.com/stac-utils/stac-rs/pull/382))
 - `stac::geoparquet::Compression`, even if geoparquet is not enabled ([#396](https://github.com/stac-utils/stac-rs/pull/396))
+- `Type` ([#397](https://github.com/stac-utils/stac-rs/pull/397))
 
 ### Changed
 
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 940a9010..4adda253 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -209,6 +209,90 @@ pub const STAC_VERSION: Version = Version::v1_0_0;
 /// Custom [Result](std::result::Result) type for this crate.
 pub type Result<T> = std::result::Result<T, Error>;
 
+/// Enum for the four "types" of STAC values.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Type {
+    /// An item.
+    Item,
+
+    /// A collection.
+    Collection,
+
+    /// A catalog.
+    Catalog,
+
+    /// An item collection.
+    ///
+    /// While not technically part of the STAC specification, it's used all over the place.
+    ItemCollection,
+}
+
+impl Type {
+    /// Returns this type as a str.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac::Type;
+    ///
+    /// assert_eq!(Type::Item.as_str(), "Feature");
+    /// ```
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            Type::Item => "Feature",
+            Type::Catalog => "Catalog",
+            Type::Collection => "Collection",
+            Type::ItemCollection => "FeatureCollection",
+        }
+    }
+
+    /// Returns the schema path for this type.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac::{Type, Version};
+    ///
+    /// assert_eq!(Type::Item.spec_path(&Version::v1_0_0).unwrap(), "/v1.0.0/item-spec/json-schema/item.json");
+    /// ```
+    pub fn spec_path(&self, version: &Version) -> Option<String> {
+        match self {
+            Type::Item => Some(format!("/v{}/item-spec/json-schema/item.json", version)),
+            Type::Catalog => Some(format!(
+                "/v{}/catalog-spec/json-schema/catalog.json",
+                version
+            )),
+            Type::Collection => Some(format!(
+                "/v{}/collection-spec/json-schema/collection.json",
+                version
+            )),
+            Type::ItemCollection => None,
+        }
+    }
+}
+
+impl std::str::FromStr for Type {
+    type Err = Error;
+    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+        match s {
+            "Feature" => Ok(Type::Item),
+            "Catalog" => Ok(Type::Catalog),
+            "Collection" => Ok(Type::Collection),
+            "FeatureCollection" => Ok(Type::ItemCollection),
+            _ => Err(Error::UnknownType(s.to_string())),
+        }
+    }
+}
+
+impl<T> PartialEq<T> for Type
+where
+    T: AsRef<str>,
+{
+    fn eq(&self, other: &T) -> bool {
+        self.as_str() == other.as_ref()
+    }
+}
+
 /// Utility function to deserialize the type field on an object.
 ///
 /// Use this, via a wrapper function, for `#[serde(deserialize_with)]`.
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 65684da4..563e6766 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -23,5 +23,5 @@ serde = "1"
 serde_json = "1"
 stac = { path = "../core", features = ["reqwest", "object-store-all"] }
 stac-api = { path = "../api", features = ["client"] }
-stac-validate = { path = "../validate" }
+stac-validate = { path = "../validate", features = ["blocking"] }
 tokio = { version = "1", features = ["rt"] }
diff --git a/python/src/validate.rs b/python/src/validate.rs
index 2d16d384..b06267e1 100644
--- a/python/src/validate.rs
+++ b/python/src/validate.rs
@@ -1,7 +1,7 @@
 use crate::{Error, Result};
 use pyo3::{prelude::*, types::PyDict};
 use stac::Value;
-use stac_validate::Validate;
+use stac_validate::ValidateBlocking;
 
 /// Validates a single href with json-schema.
 ///
@@ -42,7 +42,7 @@ pub fn validate(value: &Bound<'_, PyDict>) -> PyResult<()> {
 }
 
 fn validate_value(value: Value) -> Result<()> {
-    if let Err(error) = value.validate() {
+    if let Err(error) = value.validate_blocking() {
         match error {
             stac_validate::Error::Validation(errors) => {
                 let mut message = "Validation errors: ".to_string();
diff --git a/server/src/api.rs b/server/src/api.rs
index 20815e2f..2f7fc1d0 100644
--- a/server/src/api.rs
+++ b/server/src/api.rs
@@ -418,7 +418,7 @@ mod tests {
         let root = api.root().await.unwrap();
         assert!(!root.conformance.conforms_to.is_empty());
         let catalog: Catalog = serde_json::from_value(serde_json::to_value(root).unwrap()).unwrap();
-        catalog.validate().unwrap();
+        catalog.validate().await.unwrap();
         assert_eq!(catalog.id, "an-id");
         assert_eq!(catalog.description, "a description");
         assert_link!(
@@ -511,7 +511,7 @@ mod tests {
         );
         assert_eq!(collections.collections.len(), 1);
         let collection = &collections.collections[0];
-        collection.validate().unwrap();
+        collection.validate().await.unwrap();
         assert_link!(
             collection.link("root"),
             "http://stac.test/",
@@ -543,7 +543,7 @@ mod tests {
             .unwrap();
         let api = test_api(backend);
         let collection = api.collection("a-collection").await.unwrap().unwrap();
-        collection.validate().unwrap();
+        collection.validate().await.unwrap();
         assert_link!(
             collection.link("root"),
             "http://stac.test/",
diff --git a/validate/CHANGELOG.md b/validate/CHANGELOG.md
index 4dc114e3..1b0ffad6 100644
--- a/validate/CHANGELOG.md
+++ b/validate/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
+## Changed
+
+- Moved to async-first, with a blocking interface ([#397](https://github.com/stac-utils/stac-rs/pull/397))
+
 ## [0.2.2] - 2024-09-06
 
 ### Added
diff --git a/validate/Cargo.toml b/validate/Cargo.toml
index eca3be99..901e0ec6 100644
--- a/validate/Cargo.toml
+++ b/validate/Cargo.toml
@@ -10,17 +10,31 @@ license = "MIT OR Apache-2.0"
 keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
 categories = ["science", "data-structures"]
 
+[features]
+blocking = ["tokio/rt"]
+
 [dependencies]
 jsonschema = "0.19"
-log = "0.4"
 reqwest = { version = "0.12", features = ["blocking", "json"] }
 serde = "1"
 serde_json = "1"
 stac = { version = "0.9.0", path = "../core" }
 thiserror = "1"
+tokio = "1"
+tracing = "0.1"
 url = "2"
 
 [dev-dependencies]
 geojson = "0.24"
 stac = { version = "0.9.0", path = "../core", features = ["geo"] }
 rstest = "0.22"
+tokio = { version = "1", features = ["macros"] }
+tokio-test = "0.4"
+
+[[test]]
+name = "examples"
+required-features = ["blocking"]
+
+[[test]]
+name = "migrate"
+required-features = ["blocking"]
diff --git a/validate/README.md b/validate/README.md
index 622ef870..03eec02f 100644
--- a/validate/README.md
+++ b/validate/README.md
@@ -6,7 +6,7 @@
 ![Crates.io](https://img.shields.io/crates/l/stac-validate?style=for-the-badge)
 [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?style=for-the-badge)](./CODE_OF_CONDUCT)
 
-Validate [STAC](https://stacspec.org/) with [jsonschema](https://json-schema.org/).
+Validate [STAC](https://stacspec.org/) with [json-schema](https://json-schema.org/).
 
 ## Usage
 
@@ -22,7 +22,9 @@ stac-validate = "0.2"
 ```rust
 use stac_validate::Validate;
 let item: stac::Item = stac::read("examples/simple-item.json").unwrap();
-item.validate().unwrap();
+tokio_test::block_on(async {
+    item.validate().await.unwrap();
+});
 ```
 
 Please see the [documentation](https://docs.rs/stac-validate) for more usage examples.
diff --git a/validate/src/blocking.rs b/validate/src/blocking.rs
new file mode 100644
index 00000000..3df56b1e
--- /dev/null
+++ b/validate/src/blocking.rs
@@ -0,0 +1,82 @@
+use crate::{Result, Validate};
+use serde::Serialize;
+use tokio::runtime::Builder;
+
+/// Validate any serializable object with [json-schema](https://json-schema.org/)
+///
+/// This is a blocking alternative to [Validate]
+pub trait ValidateBlocking: Validate {
+    /// Validates this object.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_validate::ValidateBlocking;
+    /// use stac::Item;
+    ///
+    /// let mut item = Item::new("an-id");
+    /// item.validate_blocking().unwrap();
+    /// ```
+    fn validate_blocking(&self) -> Result<()> {
+        Builder::new_current_thread()
+            .enable_io()
+            .build()?
+            .block_on(self.validate())
+    }
+}
+
+impl<T: Serialize> ValidateBlocking for T {}
+
+#[cfg(test)]
+mod tests {
+    use super::ValidateBlocking;
+    use geojson::{Geometry, Value};
+    use rstest as _;
+    use stac::{Catalog, Collection, Item};
+
+    #[test]
+    fn item() {
+        let item = Item::new("an-id");
+        item.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn item_with_geometry() {
+        let mut item = Item::new("an-id");
+        item.set_geometry(Geometry::new(Value::Point(vec![-105.1, 40.1])))
+            .unwrap();
+        item.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn item_with_extensions() {
+        let item: Item =
+            stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
+        item.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn catalog() {
+        let catalog = Catalog::new("an-id", "a description");
+        catalog.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn collection() {
+        let collection = Collection::new("an-id", "a description");
+        collection.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn value() {
+        let value: stac::Value = stac::read("examples/simple-item.json").unwrap();
+        value.validate_blocking().unwrap();
+    }
+
+    #[test]
+    fn item_collection() {
+        let item = stac::read("examples/simple-item.json").unwrap();
+        let item_collection = stac::ItemCollection::from(vec![item]);
+        item_collection.validate_blocking().unwrap();
+    }
+}
diff --git a/validate/src/error.rs b/validate/src/error.rs
index fb66c2f5..09c677f3 100644
--- a/validate/src/error.rs
+++ b/validate/src/error.rs
@@ -1,5 +1,6 @@
 use jsonschema::ValidationError;
-use std::borrow::Cow;
+use serde_json::Value;
+use std::{borrow::Cow, sync::Arc};
 use thiserror::Error;
 use url::Url;
 
@@ -7,29 +8,21 @@ use url::Url;
 #[derive(Debug, Error)]
 #[non_exhaustive]
 pub enum Error {
-    /// Cannot resolve schemas with a json-schema scheme.
-    #[error("cannot resolve json-schema scheme: {0}")]
-    CannotResolveJsonSchemaScheme(Url),
+    /// Cannot validate a non-object, non-array
+    #[error("value is not an object or an array, cannot validate")]
+    CannotValidate(Value),
 
-    /// Missing stac_version.
-    #[error("missing stac_version attribute")]
-    MissingStacVersion,
-
-    /// The `stac_extensions` vector, or its contents, are not the correct type.
-    #[error("incorrect stac extensions type")]
-    IncorrectStacExtensionsType(serde_json::Value),
-
-    /// The url is not a valid file path.
-    #[error("invalid file path: {0}")]
-    InvalidFilePath(Url),
+    /// [std::io::Error]
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
 
-    /// We cannot handle this url scheme.
-    #[error("invalid url scheme: {0}")]
-    InvalidUrlScheme(Url),
+    /// No type field on an object.
+    #[error("no type field")]
+    NoType,
 
-    /// Invalid JSONSchema.
-    #[error("invalid json-schema at url: {0}")]
-    InvalidSchema(String),
+    /// No version field on an object.
+    #[error("no version field")]
+    NoVersion,
 
     /// [reqwest::Error]
     #[error(transparent)]
@@ -43,6 +36,28 @@ pub enum Error {
     #[error(transparent)]
     Stac(#[from] stac::Error),
 
+    /// [tokio::task::JoinError]
+    #[error(transparent)]
+    TokioJoin(#[from] tokio::task::JoinError),
+
+    /// [tokio::sync::mpsc::error::SendError]
+    #[error(transparent)]
+    TokioSend(
+        #[from]
+        tokio::sync::mpsc::error::SendError<(
+            Url,
+            tokio::sync::oneshot::Sender<crate::Result<Arc<Value>>>,
+        )>,
+    ),
+
+    /// [tokio::sync::oneshot::error::RecvError]
+    #[error(transparent)]
+    TokioRecv(#[from] tokio::sync::oneshot::error::RecvError),
+
+    /// [url::ParseError]
+    #[error(transparent)]
+    UrlParse(#[from] url::ParseError),
+
     /// A list of validation errors.
     ///
     /// Since we usually don't have the original [serde_json::Value] (because we
@@ -50,16 +65,14 @@ pub enum Error {
     /// lifetime.
     #[error("validation errors")]
     Validation(Vec<ValidationError<'static>>),
-
-    /// [jsonschema::ValidationError]
-    #[error(transparent)]
-    JSONSchemaValidation(#[from] ValidationError<'static>),
 }
 
 impl Error {
     /// Creates an [crate::Error] from an iterator over [jsonschema::ValidationError].
-    #[allow(single_use_lifetimes)]
-    pub fn from_validation_errors<'a>(errors: impl Iterator<Item = ValidationError<'a>>) -> Error {
+    pub fn from_validation_errors<'a, I>(errors: I) -> Error
+    where
+        I: Iterator<Item = ValidationError<'a>>,
+    {
         let mut error_vec = Vec::new();
         for error in errors {
             // Cribbed from https://docs.rs/jsonschema/latest/src/jsonschema/error.rs.html#21-30
@@ -72,15 +85,4 @@ impl Error {
         }
         Error::Validation(error_vec)
     }
-
-    /// Creates an [crate::Error] from a single [jsonschema::ValidationError].
-    pub fn from_validation_error(error: ValidationError<'_>) -> Error {
-        // Cribbed from https://docs.rs/jsonschema/latest/src/jsonschema/error.rs.html#21-30
-        Error::JSONSchemaValidation(ValidationError {
-            instance_path: error.instance_path.clone(),
-            instance: Cow::Owned(error.instance.into_owned()),
-            kind: error.kind,
-            schema_path: error.schema_path,
-        })
-    }
 }
diff --git a/validate/src/lib.rs b/validate/src/lib.rs
index 7c3223bc..39fc9335 100644
--- a/validate/src/lib.rs
+++ b/validate/src/lib.rs
@@ -1,4 +1,4 @@
-//! Validate STAC objects with jsonschema.
+//! Validate STAC objects with [json-schema](https://json-schema.org/).
 //!
 //! # Examples
 //!
@@ -7,23 +7,40 @@
 //! ```
 //! use stac::Item;
 //! use stac_validate::Validate;
-//! Item::new("an-id").validate().unwrap();
+//!
+//! # tokio_test::block_on(async {
+//! Item::new("an-id").validate().await.unwrap();
+//! # })
 //! ```
 //!
-//! [stac::Collection], [stac::Catalog], and [stac::Item] all have their schemas built into the library, so they don't need to be fetched from the network.
-//! Any extension schemas are fetched using [reqwest](https://docs.rs/reqwest/latest/reqwest/), and cached for later use.
-//! This means that, if you're doing multiple validations, you should re-use the same [Validator]:
+//! If you're working in a blocking context (not async), enable the `blocking` feature and use [ValidateBlocking]:
 //!
 //! ```
 //! # use stac::Item;
-//! use stac_validate::Validator;
+//! #[cfg(feature = "blocking")]
+//! {
+//!     use stac_validate::ValidateBlocking;
+//!     Item::new("an-id").validate_blocking().unwrap();
+//! }
+//! ```
 //!
+//! All fetched schemas are cached, so if you're you're doing multiple
+//! validations, you should re-use the same [Validator]:
+//!
+//! ```
+//! # use stac::Item;
+//! # use stac_validate::Validator;
 //! let mut items: Vec<_> = (0..10).map(|n| Item::new(format!("item-{}", n))).collect();
-//! let mut validator = Validator::new();
+//! # tokio_test::block_on(async {
+//! let mut validator = Validator::new().await;
 //! for item in items {
-//!     validator.validate(item).unwrap();
+//!     validator.validate(&item).await.unwrap();
 //! }
+//! # })
 //! ```
+//!
+//! [Validator] is cheap to clone, so you are encouraged to validate a large
+//! number of objects at the same time if that's your use-case.
 
 #![deny(
     elided_lifetimes_in_paths,
@@ -54,72 +71,21 @@
     unused_results
 )]
 
+#[cfg(feature = "blocking")]
+mod blocking;
 mod error;
 mod validate;
 mod validator;
 
-pub use {
-    error::Error,
-    validate::{Validate, ValidateCore},
-    validator::Validator,
-};
+#[cfg(feature = "blocking")]
+pub use blocking::ValidateBlocking;
+pub use {error::Error, validate::Validate, validator::Validator};
 
 /// Crate-specific result type.
 pub type Result<T> = std::result::Result<T, Error>;
 
 #[cfg(test)]
-mod tests {
-    use crate::Validate;
-    use geojson::{Geometry, Value};
-    use rstest as _;
-    use stac::{Catalog, Collection, Item};
-
-    #[test]
-    fn item() {
-        let item = Item::new("an-id");
-        item.validate().unwrap();
-    }
-
-    #[test]
-    fn item_with_geometry() {
-        let mut item = Item::new("an-id");
-        item.set_geometry(Geometry::new(Value::Point(vec![-105.1, 40.1])))
-            .unwrap();
-        item.validate().unwrap();
-    }
-
-    #[test]
-    fn item_with_extensions() {
-        let item: Item =
-            stac::read("examples/extensions-collection/proj-example/proj-example.json").unwrap();
-        item.validate().unwrap();
-    }
-
-    #[test]
-    fn catalog() {
-        let catalog = Catalog::new("an-id", "a description");
-        catalog.validate().unwrap();
-    }
-
-    #[test]
-    fn collection() {
-        let collection = Collection::new("an-id", "a description");
-        collection.validate().unwrap();
-    }
-
-    #[test]
-    fn value() {
-        let value: stac::Value = stac::read("examples/simple-item.json").unwrap();
-        value.validate().unwrap();
-    }
-
-    #[test]
-    fn item_collection() {
-        let item = stac::read("examples/simple-item.json").unwrap();
-        let item_collection = stac::ItemCollection::from(vec![item]);
-        item_collection.validate().unwrap();
-    }
-}
+use {geojson as _, rstest as _, tokio_test as _};
 
 // From https://github.com/rust-lang/cargo/issues/383#issuecomment-720873790,
 // may they be forever blessed.
diff --git a/validate/src/validate.rs b/validate/src/validate.rs
index 11bd20dd..891ba5df 100644
--- a/validate/src/validate.rs
+++ b/validate/src/validate.rs
@@ -1,153 +1,35 @@
-use crate::{Error, Result, Validator};
+use crate::{Result, Validator};
 use serde::Serialize;
-use serde_json::Value;
-use stac::{Catalog, Collection, Item, ItemCollection};
+use std::future::Future;
 
-/// Trait for validating STAC objects using [jsonschema].
-pub trait Validate: ValidateCore {
-    /// Validate this STAC object using [jsonschema].
+/// Validate any serializable object with [json-schema](https://json-schema.org/)
+pub trait Validate: Serialize + Sized {
+    /// Validates this object.
     ///
-    /// #  Examples
+    /// If the object fails validation, this will return an
+    /// [Error::Validation](crate::Error::Validation) which contains a vector of
+    /// all of the validation errors.
     ///
-    /// [stac::Item] implements [Validate]:
+    /// If you're doing multiple validations, use [Validator::validate], which
+    /// will re-use cached schemas.
     ///
-    /// ```
-    /// use stac_validate::Validate;
-    /// let item = stac::Item::new("an-id");
-    /// item.validate().unwrap();
-    /// ```
-    fn validate(&self) -> Result<()> {
-        let mut validator = Validator::new();
-        self.validate_with_validator(&mut validator)
-    }
-
-    /// Validates a STAC object with the provided validator.
-    ///
-    /// #  Examples
+    /// # Examples
     ///
-    /// [stac::Item] implements [Validate]:
-    ///
-    /// ```
-    /// use stac_validate::{Validate, Validator};
-    ///
-    /// let mut validator = Validator::new();
-    /// let item = stac::Item::new("an-id");
-    /// item.validate_with_validator(&mut validator).unwrap();
     /// ```
-    fn validate_with_validator(&self, validator: &mut Validator) -> Result<()> {
-        let value = serde_json::to_value(self)?;
-        let mut errors = match Self::validate_core_json(&value, validator) {
-            Ok(()) => Vec::new(),
-            Err(Error::Validation(errors)) => errors,
-            Err(err) => return Err(err),
-        };
-        match validator.validate_extensions(&value) {
-            Ok(()) => {}
-            Err(Error::Validation(extension_errors)) => errors.extend(extension_errors),
-            Err(err) => return Err(err),
-        }
-        if errors.is_empty() {
-            Ok(())
-        } else {
-            Err(Error::Validation(errors))
-        }
-    }
-}
-
-/// Trait for validating STAC objects against their core schema only.
-///
-/// If your STAC object is the same version as [stac::STAC_VERSION], this will
-/// be a quick, cheap operation, since the schemas are stored in the library.
-pub trait ValidateCore: Serialize {
-    /// Validate a [serde_json::Value] against a specific STAC jsonschema.
-    ///
-    /// #  Examples
-    ///
-    /// [stac::Item] implements [ValidateCore]:
-    ///
-    /// ```
-    /// use stac_validate::ValidateCore;
+    /// use stac_validate::Validate;
     /// use stac::Item;
     ///
-    /// let item = Item::new("an-id");
-    /// let value = serde_json::to_value(item).unwrap();
-    /// Item::validate_core_json(&value, &mut stac_validate::Validator::new()).unwrap();
+    /// let mut item = Item::new("an-id");
+    /// # tokio_test::block_on(async {
+    /// item.validate().await.unwrap();
+    /// });
     /// ```
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()>;
-}
-
-impl Validate for Item {}
-
-impl ValidateCore for Item {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        validator.validate_item(value)
-    }
-}
-
-impl Validate for Catalog {}
-
-impl ValidateCore for Catalog {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        validator.validate_catalog(value)
-    }
-}
-
-impl Validate for Collection {}
-
-impl ValidateCore for Collection {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        validator.validate_collection(value)
-    }
-}
-
-impl Validate for stac::Value {}
-
-impl ValidateCore for stac::Value {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        if let Some(type_) = value.get("type") {
-            if let Some(type_) = type_.as_str() {
-                match type_ {
-                    "Feature" => validator.validate_item(value),
-                    "Collection" => validator.validate_collection(value),
-                    "Catalog" => validator.validate_catalog(value),
-                    "FeatureCollection" => validator.validate_item_collection(value),
-                    _ => Err(stac::Error::UnknownType(type_.to_string()).into()),
-                }
-            } else {
-                Err(stac::Error::InvalidTypeField(type_.clone()).into())
-            }
-        } else {
-            Err(stac::Error::MissingType.into())
+    fn validate(&self) -> impl Future<Output = Result<()>> {
+        async {
+            let validator = Validator::new().await;
+            validator.validate(self).await
         }
     }
 }
 
-impl Validate for ItemCollection {}
-
-impl ValidateCore for ItemCollection {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        validator.validate_item_collection(value)
-    }
-}
-
-impl Validate for Value {}
-
-impl ValidateCore for Value {
-    fn validate_core_json(value: &Value, validator: &mut Validator) -> Result<()> {
-        if let Some(type_) = value.get("type") {
-            if let Some(type_) = type_.as_str() {
-                match type_ {
-                    "Feature" => validator.validate_item(value),
-                    "Collection" => validator.validate_collection(value),
-                    "Catalog" => validator.validate_catalog(value),
-                    "FeatureCollection" => validator.validate_item_collection(value),
-                    _ => Err(stac::Error::UnknownType(type_.to_string()).into()),
-                }
-            } else {
-                Err(stac::Error::InvalidTypeField(type_.clone()).into())
-            }
-        } else {
-            Err(stac::Error::MissingType.into())
-        }
-    }
-}
+impl<T: Serialize> Validate for T {}
diff --git a/validate/src/validator.rs b/validate/src/validator.rs
index 18f32709..a0eb1b33 100644
--- a/validate/src/validator.rs
+++ b/validate/src/validator.rs
@@ -1,30 +1,42 @@
-use crate::{Error, Result, Validate};
+use crate::{Error, Result};
 use jsonschema::{CompilationOptions, JSONSchema, SchemaResolver, SchemaResolverError};
-use serde_json::Value;
-use stac::Version;
+use reqwest::Client;
+use serde::Serialize;
+use serde_json::{Map, Value};
+use stac::{Type, Version};
 use std::{
-    collections::HashMap,
-    io::BufReader,
-    sync::Arc,
-    thread::{self, JoinHandle},
+    collections::{HashMap, HashSet},
+    future::Future,
+    pin::Pin,
+    sync::{Arc, Mutex},
+};
+use tokio::{
+    sync::{
+        mpsc::{error::TryRecvError, Receiver, Sender},
+        oneshot::Sender as OneshotSender,
+        RwLock,
+    },
+    task::JoinSet,
 };
 use url::Url;
 
-/// A structure for validating one or more STAC objects.
-///
-/// The structure stores the core json schemas, and has a cache for extension
-/// schemas.
-#[derive(Debug)]
+const SCHEMA_BASE: &str = "https://schemas.stacspec.org";
+const BUFFER: usize = 10;
+
+/// A cloneable structure for validating STAC.
+#[derive(Clone, Debug)]
 pub struct Validator {
-    item_schemas: HashMap<Version, JSONSchema>,
-    catalog_schemas: HashMap<Version, JSONSchema>,
-    collection_schemas: HashMap<Version, JSONSchema>,
-    extension_schemas: HashMap<String, JSONSchema>,
     compilation_options: CompilationOptions,
+    cache: Arc<std::sync::RwLock<HashMap<Url, Arc<Value>>>>,
+    schemas: Arc<RwLock<HashMap<Url, Arc<JSONSchema>>>>,
+    urls: Arc<Mutex<HashSet<Url>>>,
+    sender: Sender<(Url, OneshotSender<Result<Arc<Value>>>)>,
 }
 
-#[derive(Default)]
-struct Resolver;
+struct Resolver {
+    cache: Arc<std::sync::RwLock<HashMap<Url, Arc<Value>>>>,
+    urls: Arc<Mutex<HashSet<Url>>>,
+}
 
 impl Validator {
     /// Creates a new validator.
@@ -33,226 +45,213 @@ impl Validator {
     ///
     /// ```
     /// use stac_validate::Validator;
-    /// let validator = Validator::new();
+    ///
+    /// # tokio_test::block_on(async {
+    /// let validator = Validator::new().await;
+    /// });
     /// ```
-    pub fn new() -> Validator {
-        let mut options = JSONSchema::options();
-        let options_with_resolver = options.with_resolver(Resolver);
-        let mut item_schemas = HashMap::new();
-        let _ = item_schemas.insert(
-            Version::v1_0_0,
-            options_with_resolver
-                .compile(
-                    &serde_json::from_str(include_str!("../schemas/v1.0.0/item.json")).unwrap(),
-                )
-                .unwrap(),
-        );
-        let mut catalog_schemas = HashMap::new();
-        let _ = catalog_schemas.insert(
-            Version::v1_0_0,
-            options_with_resolver
-                .compile(
-                    &serde_json::from_str(include_str!("../schemas/v1.0.0/catalog.json")).unwrap(),
-                )
-                .unwrap(),
-        );
-        let mut collection_schemas = HashMap::new();
-        let _ = collection_schemas.insert(
-            Version::v1_0_0,
-            options_with_resolver
-                .compile(
-                    &serde_json::from_str(include_str!("../schemas/v1.0.0/collection.json"))
-                        .unwrap(),
-                )
-                .unwrap(),
-        );
-        Validator {
-            item_schemas,
-            catalog_schemas,
-            collection_schemas,
-            extension_schemas: HashMap::new(),
-            compilation_options: options,
-        }
+    pub async fn new() -> Validator {
+        let cache = Arc::new(std::sync::RwLock::new(cache()));
+        let urls = Arc::new(Mutex::new(HashSet::new()));
+        let resolver = Resolver {
+            cache: cache.clone(),
+            urls: urls.clone(),
+        };
+        let mut compilation_options = JSONSchema::options();
+        let _ = compilation_options.with_resolver(resolver);
+        let (sender, receiver) = tokio::sync::mpsc::channel(BUFFER);
+        let _ = tokio::spawn(async move { get_urls(receiver).await });
+        let validator = Validator {
+            schemas: Arc::new(RwLock::new(schemas(&compilation_options))),
+            compilation_options,
+            cache,
+            urls,
+            sender,
+        };
+        validator
     }
 
-    /// Validates a STAC object.
-    ///
-    /// Needs to be mutable because extension schemas are cached.
+    /// Validates a single value.
     ///
     /// # Examples
     ///
     /// ```
     /// use stac_validate::Validator;
-    /// let mut validator = Validator::new();
-    /// validator.validate(stac::Item::new("an-id")).unwrap();
+    /// use stac::Item;
+    ///
+    /// let item = Item::new("an-id");
+    /// # tokio_test::block_on(async {
+    /// let validator = Validator::new().await;
+    /// validator.validate(&item).await.unwrap();
+    /// });
     /// ```
-    pub fn validate<V: Validate>(&mut self, validatable: V) -> Result<()> {
-        validatable.validate_with_validator(self)
-    }
-
-    pub(crate) fn validate_item(&mut self, item: &Value) -> Result<()> {
-        let version = version(item)?;
-        self.item_schema_for(version)?
-            .validate(item)
-            .map_err(Error::from_validation_errors)
-    }
-
-    fn item_schema_for(&mut self, version: Version) -> Result<&JSONSchema> {
-        if !self.item_schemas.contains_key(&version) {
-            let schema = self.fetch_schema(&format!(
-                "https://schemas.stacspec.org/v{}/item-spec/json-schema/item.json",
-                version
-            ))?;
-            let _ = self.item_schemas.insert(version.clone(), schema);
-        }
-        Ok(self
-            .item_schemas
-            .get(&version)
-            .expect("if we didn't have it, we should have just fetched it"))
-    }
-
-    pub(crate) fn validate_catalog(&mut self, catalog: &Value) -> Result<()> {
-        let version = version(catalog)?;
-        self.catalog_schema_for(version)?
-            .validate(catalog)
-            .map_err(Error::from_validation_errors)
-    }
-
-    fn catalog_schema_for(&mut self, version: Version) -> Result<&JSONSchema> {
-        if !self.catalog_schemas.contains_key(&version) {
-            let schema = self.fetch_schema(&format!(
-                "https://schemas.stacspec.org/v{}/catalog-spec/json-schema/catalog.json",
-                version
-            ))?;
-            let _ = self.catalog_schemas.insert(version.clone(), schema);
-        }
-        Ok(self
-            .catalog_schemas
-            .get(&version)
-            .expect("if we didn't have it, we should have just fetched it"))
-    }
-
-    pub(crate) fn validate_collection(&mut self, collection: &Value) -> Result<()> {
-        let version = version(collection)?;
-        self.collection_schema_for(version)?
-            .validate(collection)
-            .map_err(Error::from_validation_errors)
-    }
-
-    fn collection_schema_for(&mut self, version: Version) -> Result<&JSONSchema> {
-        if !self.collection_schemas.contains_key(&version) {
-            let schema = self.fetch_schema(&format!(
-                "https://schemas.stacspec.org/v{}/collection-spec/json-schema/collection.json",
-                version
-            ))?;
-            let _ = self.collection_schemas.insert(version.clone(), schema);
+    pub async fn validate<T>(&self, value: &T) -> Result<()>
+    where
+        T: Serialize,
+    {
+        let value = serde_json::to_value(value)?;
+        if let Value::Object(object) = value {
+            self.validate_object(object).await
+        } else if let Value::Array(array) = value {
+            self.validate_array(array).await
+        } else {
+            Err(Error::CannotValidate(value))
         }
-        Ok(self
-            .collection_schemas
-            .get(&version)
-            .expect("if we didn't have it, we should have just fetched it"))
     }
 
-    fn fetch_schema(&mut self, url: &str) -> Result<JSONSchema> {
-        let response = reqwest::blocking::get(url)?;
-        let value: Value = response.json()?;
-        self.compilation_options
-            .compile(&value)
-            .map_err(|_| Error::InvalidSchema(url.to_string()))
-    }
-
-    pub(crate) fn validate_item_collection(&mut self, item_collection: &Value) -> Result<()> {
-        if let Some(items) = item_collection.get("features") {
-            if let Some(items) = items.as_array() {
-                let mut errors = Vec::new();
-                for item in items {
-                    match self.validate_item(item) {
-                        Ok(()) => {}
-                        Err(Error::Validation(e)) => errors.extend(e),
-                        Err(e) => return Err(e),
+    fn validate_array(&self, array: Vec<Value>) -> Pin<Box<impl Future<Output = Result<()>>>> {
+        // We have to pinbox because recursive async aren't allowed.
+        let validator = self.clone();
+        Box::pin(async move {
+            let mut errors = Vec::new();
+            for value in array {
+                if let Err(error) = validator.validate(&value).await {
+                    if let Error::Validation(e) = error {
+                        errors.extend(e);
+                    } else {
+                        return Err(error);
                     }
                 }
-                if errors.is_empty() {
-                    Ok(())
-                } else {
-                    Err(Error::Validation(errors))
-                }
+            }
+            if errors.is_empty() {
+                Ok(())
             } else {
-                // FIXME these errors aren't quite correct but there's probably
-                // a refactor to be done around getting the items from a
-                // serde_json::Value feature collection.
-                Err(stac::Error::UnknownType("features".to_string()).into())
+                Err(Error::Validation(errors))
             }
-        } else {
-            Err(stac::Error::MissingType.into())
-        }
+        })
     }
 
-    pub(crate) fn validate_extensions(&mut self, value: &Value) -> Result<()> {
-        if let Some(extensions) = value.get("stac_extensions") {
-            if let Some(extensions) = extensions.as_array() {
-                let mut errors = Vec::new();
-                let mut handles: Vec<JoinHandle<_>> = Vec::new();
-                for extension in extensions {
-                    if let Some(extension) = extension.as_str() {
-                        if let Some(schema) = self.extension_schemas.get(extension) {
-                            match self.validate_extension(value, schema) {
-                                Ok(()) => {}
-                                Err(Error::Validation(extension_errors)) => {
-                                    errors.extend(extension_errors)
-                                }
-                                Err(error) => {
-                                    for handle in handles {
-                                        let _ = handle.join().unwrap();
-                                    }
-                                    return Err(error);
-                                }
-                            }
-                        } else {
-                            let extension = extension.to_string();
-                            handles.push(thread::spawn(move || get_extension(extension)));
-                        }
+    fn validate_object(
+        &self,
+        object: Map<String, Value>,
+    ) -> Pin<Box<impl Future<Output = Result<()>>>> {
+        // We have to pinbox because recursive async aren't allowed.
+        let validator = self.clone();
+        Box::pin(async move {
+            let r#type: Type = object
+                .get("type")
+                .and_then(|v| v.as_str())
+                .map(|t| t.parse::<Type>())
+                .transpose()?
+                .ok_or(Error::NoType)?;
+            if r#type == Type::ItemCollection {
+                if let Some(features) = object.get("features") {
+                    return validator.validate(features).await;
+                } else {
+                    return Ok(());
+                }
+            }
+            let version: Version = object
+                .get("stac_version")
+                .and_then(|v| v.as_str())
+                .map(|v| v.parse::<Version>())
+                .transpose()
+                .unwrap()
+                .ok_or(Error::NoVersion)?;
+
+            let url = build_schema_url(r#type, &version);
+            let schema = validator.schema(url).await?;
+            let value = Arc::new(Value::Object(object));
+            let mut result = schema
+                .validate(&value)
+                .map_err(Error::from_validation_errors);
+            if result.is_ok() {
+                result = validator.validate_extensions(value.clone()).await
+            }
+            if let Err(err) = result {
+                let mut join_set = JoinSet::new();
+                {
+                    let mut urls = validator.urls.lock().unwrap();
+                    if urls.is_empty() {
+                        return Err(err);
                     } else {
-                        for handle in handles {
-                            let _ = handle.join().unwrap();
+                        for url in urls.drain() {
+                            let validator = validator.clone();
+                            let _ = join_set.spawn(async move { validator.resolve(url).await });
                         }
-                        return Err(Error::IncorrectStacExtensionsType(extension.clone()));
                     }
                 }
-                for handle in handles {
-                    let (href, schema) = handle.join().unwrap()?;
-                    match self.validate_extension(value, &schema) {
-                        Ok(()) => {}
-                        Err(Error::Validation(extension_errors)) => errors.extend(extension_errors),
-                        Err(error) => {
-                            return Err(error);
-                        }
-                    }
-                    let _ = self.extension_schemas.insert(href, schema);
+                while let Some(result) = join_set.join_next().await {
+                    let _ = result??;
                 }
-                if errors.is_empty() {
-                    Ok(())
+                let object = if let Value::Object(o) = Arc::into_inner(value).unwrap() {
+                    o
                 } else {
-                    Err(Error::Validation(errors))
-                }
+                    unreachable!()
+                };
+                validator.validate_object(object).await
             } else {
-                Err(Error::IncorrectStacExtensionsType(extensions.clone()))
+                Ok(())
             }
-        } else {
+        })
+    }
+
+    async fn validate_extensions(&self, value: Arc<Value>) -> Result<()> {
+        let mut join_set = JoinSet::new();
+        if let Some(extensions) = value
+            .as_object()
+            .and_then(|o| o.get("stac_extensions"))
+            .and_then(|v| v.as_array())
+        {
+            for extension in extensions.iter().filter_map(|v| v.as_str()) {
+                let extension = Url::parse(extension)?;
+                let validator = self.clone();
+                let value = value.clone();
+                let _ = join_set.spawn(async move {
+                    let extension_schema = validator.schema(extension).await?;
+                    extension_schema
+                        .validate(&value)
+                        .map_err(Error::from_validation_errors)
+                });
+            }
+        }
+        let mut errors = Vec::new();
+        while let Some(result) = join_set.join_next().await {
+            let result = result?;
+            if let Err(Error::Validation(e)) = result {
+                errors.extend(e);
+            } else if result.is_err() {
+                return result;
+            }
+        }
+        if errors.is_empty() {
             Ok(())
+        } else {
+            Err(Error::Validation(errors))
         }
     }
 
-    fn validate_extension(&self, value: &Value, extension: &JSONSchema) -> Result<()> {
-        extension
-            .validate(value)
-            .map_err(Error::from_validation_errors)
+    async fn schema(&self, url: Url) -> Result<Arc<JSONSchema>> {
+        {
+            let schemas = self.schemas.read().await;
+            if let Some(schema) = schemas.get(&url) {
+                return Ok(schema.clone());
+            }
+        }
+        let (sender, receiver) = tokio::sync::oneshot::channel();
+        self.sender.send((url.clone(), sender)).await?;
+        let value = receiver.await??;
+        let schema = self
+            .compilation_options
+            .compile(&value)
+            .map_err(|err| Error::from_validation_errors([err].into_iter()))?;
+        let schema = Arc::new(schema);
+        {
+            let mut schemas = self.schemas.write().await;
+            let _ = schemas.insert(url, schema.clone());
+        }
+        Ok(schema)
     }
-}
 
-impl Default for Validator {
-    fn default() -> Self {
-        Self::new()
+    async fn resolve(&self, url: Url) -> Result<()> {
+        let (sender, receiver) = tokio::sync::oneshot::channel();
+        self.sender.send((url.clone(), sender)).await?;
+        let value = receiver.await??;
+        {
+            let mut cache = self.cache.write().unwrap();
+            let _ = cache.insert(url, value);
+        }
+        Ok(())
     }
 }
 
@@ -263,91 +262,190 @@ impl SchemaResolver for Resolver {
         url: &Url,
         _: &str,
     ) -> std::result::Result<Arc<Value>, SchemaResolverError> {
-        match url.as_str() {
-            "https://geojson.org/schema/Feature.json" => Ok(Arc::new(
-                serde_json::from_str(include_str!("../schemas/geojson/Feature.json")).unwrap(),
-            )),
-            "https://geojson.org/schema/Geometry.json" => Ok(Arc::new(
-                serde_json::from_str(include_str!("../schemas/geojson/Geometry.json")).unwrap(),
-            )),
-            // Include the item schema because collection references it
-            // https://github.com/stac-utils/stac-rs/issues/336
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json" => Ok(Arc::new(
-                serde_json::from_str(include_str!("../schemas/v1.0.0/item.json")).unwrap(),
-            )),
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/basics.json" => {
-                Ok(Arc::new(
-                    serde_json::from_str(include_str!("../schemas/v1.0.0/basics.json")).unwrap(),
-                ))
-            }
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json" => {
-                Ok(Arc::new(
-                    serde_json::from_str(include_str!("../schemas/v1.0.0/datetime.json")).unwrap(),
-                ))
+        {
+            let cache = self.cache.read().unwrap();
+            if let Some(schema) = cache.get(url) {
+                return Ok(schema.clone());
             }
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/instrument.json" => {
-                Ok(Arc::new(
-                    serde_json::from_str(include_str!("../schemas/v1.0.0/instrument.json"))
-                        .unwrap(),
-                ))
-            }
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/licensing.json" => {
-                Ok(Arc::new(
-                    serde_json::from_str(include_str!("../schemas/v1.0.0/licensing.json")).unwrap(),
-                ))
-            }
-            "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/provider.json" => {
-                Ok(Arc::new(
-                    serde_json::from_str(include_str!("../schemas/v1.0.0/provider.json")).unwrap(),
-                ))
-            }
-            "http://json-schema.org/draft-07/schema"
-            | "https://json-schema.org/draft-07/schema" => Ok(Arc::new(
-                serde_json::from_str(include_str!("../schemas/json-schema/draft-07.json")).unwrap(),
-            )),
-            _ => match url.scheme() {
-                "http" | "https" => {
-                    log::debug!("fetching schema: {}", url);
-                    let response = reqwest::blocking::get(url.as_str())?;
-                    let document: Value = response.json()?;
-                    Ok(Arc::new(document))
-                }
-                "file" => {
-                    if let Ok(path) = url.to_file_path() {
-                        let f = std::fs::File::open(path)?;
-                        let document: Value = serde_json::from_reader(BufReader::new(f))?;
-                        Ok(Arc::new(document))
-                    } else {
-                        Err(Error::InvalidFilePath(url.clone()).into())
-                    }
+        }
+        {
+            let mut urls = self.urls.lock().unwrap();
+            let _ = urls.insert(url.clone());
+        }
+        Err(SchemaResolverError::msg("need to resolve and re-try"))
+    }
+}
+
+fn build_schema_url(r#type: Type, version: &Version) -> Url {
+    Url::parse(&format!(
+        "{}{}",
+        SCHEMA_BASE,
+        r#type
+            .spec_path(version)
+            .expect("we shouldn't get here with an item collection")
+    ))
+    .unwrap()
+}
+
+fn schemas(compilation_options: &CompilationOptions) -> HashMap<Url, Arc<JSONSchema>> {
+    use Type::*;
+    use Version::*;
+
+    let mut schemas = HashMap::new();
+
+    macro_rules! schema {
+        ($t:expr, $v:expr, $path:expr, $schemas:expr) => {
+            let url = build_schema_url($t, &$v);
+            let schema = serde_json::from_str(include_str!($path)).unwrap();
+            let schema = compilation_options.compile(&schema).unwrap();
+            let _ = schemas.insert(url, Arc::new(schema));
+        };
+    }
+
+    schema!(Item, v1_0_0, "../schemas/v1.0.0/item.json", schemas);
+    schema!(Catalog, v1_0_0, "../schemas/v1.0.0/catalog.json", schemas);
+    schema!(
+        Collection,
+        v1_0_0,
+        "../schemas/v1.0.0/collection.json",
+        schemas
+    );
+
+    schemas
+}
+
+fn cache() -> HashMap<Url, Arc<Value>> {
+    let mut cache = HashMap::new();
+
+    macro_rules! resolve {
+        ($url:expr, $path:expr) => {
+            let _ = cache.insert(
+                Url::parse($url).unwrap(),
+                Arc::new(serde_json::from_str(include_str!($path)).unwrap()),
+            );
+        };
+    }
+
+    resolve!(
+        "https://geojson.org/schema/Feature.json",
+        "../schemas/geojson/Feature.json"
+    );
+    resolve!(
+        "https://geojson.org/schema/Geometry.json",
+        "../schemas/geojson/Geometry.json"
+    );
+    resolve!(
+        "http://json-schema.org/draft-07/schema",
+        "../schemas/json-schema/draft-07.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/basics.json",
+        "../schemas/v1.0.0/basics.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json",
+        "../schemas/v1.0.0/datetime.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/instrument.json",
+        "../schemas/v1.0.0/instrument.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json",
+        "../schemas/v1.0.0/item.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/licensing.json",
+        "../schemas/v1.0.0/licensing.json"
+    );
+    resolve!(
+        "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/provider.json",
+        "../schemas/v1.0.0/provider.json"
+    );
+
+    cache
+}
+
+async fn get_urls(mut receiver: Receiver<(Url, OneshotSender<Result<Arc<Value>>>)>) -> Result<()> {
+    let mut cache: HashMap<Url, Arc<Value>> = HashMap::new();
+    let mut gets: HashMap<Url, Vec<OneshotSender<Result<Arc<Value>>>>> = HashMap::new();
+    let client = Client::new();
+    let (local_sender, mut local_receiver) = tokio::sync::mpsc::channel(BUFFER);
+    loop {
+        match receiver.try_recv() {
+            Err(TryRecvError::Disconnected) => return Ok(()),
+            Err(TryRecvError::Empty) => match local_receiver.try_recv() {
+                Err(TryRecvError::Disconnected) => return Ok(()),
+                Err(TryRecvError::Empty) => tokio::task::yield_now().await,
+                Ok((url, result)) => {
+                    let mut senders = gets
+                        .remove(&url)
+                        .expect("all sent values should be in gets");
+                    match result {
+                        Ok(value) => {
+                            let value = Arc::<Value>::new(value);
+                            let _ = cache.insert(url, value.clone());
+                            for sender in senders {
+                                sender.send(Ok(value.clone())).unwrap();
+                            }
+                        }
+                        Err(err) => {
+                            senders
+                                .pop()
+                                .expect("there should be at least one sender")
+                                .send(Err(err))
+                                .unwrap();
+                        }
+                    };
                 }
-                "json-schema" => Err(Error::CannotResolveJsonSchemaScheme(url.clone()).into()),
-                _ => Err(Error::InvalidUrlScheme(url.clone()).into()),
             },
+            Ok((url, sender)) => {
+                if let Some(value) = cache.get(&url) {
+                    sender.send(Ok(value.clone())).unwrap();
+                } else {
+                    gets.entry(url.clone())
+                        .or_insert_with(|| {
+                            tracing::debug!("getting url: {}", url);
+                            let local_sender = local_sender.clone();
+                            let client = client.clone();
+                            let _ = tokio::spawn(async move {
+                                match get(client, url.clone()).await {
+                                    Ok(value) => local_sender.send((url, Ok(value))).await,
+                                    Err(err) => local_sender.send((url, Err(err))).await,
+                                }
+                            });
+                            Vec::new()
+                        })
+                        .push(sender);
+                }
+            }
         }
     }
 }
 
-fn get_extension(href: String) -> Result<(String, JSONSchema)> {
-    log::debug!("fetching extension href: {}", href);
-    let response = reqwest::blocking::get(&href)?;
-    let response = response.error_for_status()?;
-    let json: Value = response.json()?;
-    let mut options = JSONSchema::options();
-    let options = options.with_resolver(Resolver);
-    let schema = options
-        .compile(&json)
-        .map_err(Error::from_validation_error)?;
-    Ok((href, schema))
+async fn get(client: Client, url: Url) -> Result<Value> {
+    client
+        .get(url)
+        .send()
+        .await?
+        .error_for_status()?
+        .json()
+        .await
+        .map_err(Error::from)
 }
 
-fn version(value: &Value) -> Result<Version> {
-    value
-        .as_object()
-        .and_then(|object| object.get("stac_version"))
-        .and_then(|value| value.as_str())
-        .map(|version| version.parse())
-        .transpose()
-        .unwrap()
-        .ok_or(Error::MissingStacVersion)
+#[cfg(test)]
+mod tests {
+    use super::Validator;
+    use stac::Item;
+
+    #[tokio::test]
+    async fn validate_array() {
+        let items: Vec<_> = (0..100)
+            .map(|i| Item::new(format!("item-{}", i)))
+            .map(|i| serde_json::to_value(i).unwrap())
+            .collect();
+        let validator = Validator::new().await;
+        validator.validate(&items).await.unwrap();
+    }
 }


Test patch:
diff --git a/validate/tests/examples.rs b/validate/tests/examples.rs
index 2e583c51..44153cbf 100644
--- a/validate/tests/examples.rs
+++ b/validate/tests/examples.rs
@@ -1,16 +1,16 @@
 use rstest::rstest;
 use stac::Value;
-use stac_validate::Validate;
+use stac_validate::ValidateBlocking;
 use std::path::PathBuf;
 
 #[rstest]
 fn v1_0_0(#[files("../spec-examples/v1.0.0/**/*.json")] path: PathBuf) {
     let value: Value = stac::read(path.to_str().unwrap()).unwrap();
-    value.validate().unwrap();
+    value.validate_blocking().unwrap();
 }
 
 #[rstest]
 fn v1_1_0_beta_1(#[files("../spec-examples/v1.1.0-beta.1/**/*.json")] path: PathBuf) {
     let value: Value = stac::read(path.to_str().unwrap()).unwrap();
-    value.validate().unwrap();
+    value.validate_blocking().unwrap();
 }
diff --git a/validate/tests/migrate.rs b/validate/tests/migrate.rs
index ab282266..a8fcdc96 100644
--- a/validate/tests/migrate.rs
+++ b/validate/tests/migrate.rs
@@ -1,11 +1,11 @@
 use rstest::rstest;
 use stac::{Migrate, Value, Version};
-use stac_validate::Validate;
+use stac_validate::ValidateBlocking;
 use std::path::PathBuf;
 
 #[rstest]
 fn v1_0_0_to_v1_1_0_beta_1(#[files("../spec-examples/v1.0.0/**/*.json")] path: PathBuf) {
     let value: Value = stac::read(path.to_str().unwrap()).unwrap();
     let value = value.migrate(&Version::v1_1_0_beta_1).unwrap();
-    value.validate().unwrap();
+    value.validate_blocking().unwrap();
 }