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

Issue:
STAC v1.1.0-beta.1
## Related issues

- Closes #290

## Description

Adds validation and migration for STAC v1.1.0, including CLI support that can be used in support of the release itself.

## Checklist

- [x] Unit tests
- [x] Documentation, including doctests
- [x] Git history is linear
- [x] Commit messages are descriptive
- [x] (optional) Git commit messages follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
- [x] Code is formatted (`cargo fmt`)
- [x] `cargo test`
- [ ] Changes are added to the CHANGELOG



Discussion / hints:

Fix patch:
diff --git a/stac-cli/CHANGELOG.md b/stac-cli/CHANGELOG.md
index 19736873..3f510c3d 100644
--- a/stac-cli/CHANGELOG.md
+++ b/stac-cli/CHANGELOG.md
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
+### Added
+
+- `migrate` subcommand ([#294](https://github.com/stac-utils/stac-rs/pull/294))
+
 ### Changed
 
 - Switch to using structures for command arguments, and move the `execute` methods to those structures ([#285](https://github.com/stac-utils/stac-rs/pull/285))
diff --git a/stac-cli/src/args.rs b/stac-cli/src/args.rs
index a0e332f4..931d2783 100644
--- a/stac-cli/src/args.rs
+++ b/stac-cli/src/args.rs
@@ -1,5 +1,6 @@
 use crate::{Format, Result, Subcommand};
 use clap::Parser;
+use stac::{Version, STAC_VERSION};
 use std::{fs::File, io::Write};
 
 /// CLI arguments.
@@ -196,6 +197,25 @@ pub struct TranslateArgs {
     pub outfile: Option<String>,
 }
 
+/// Arguments for migrating STAC values.
+#[derive(clap::Args, Debug)]
+pub struct MigrateArgs {
+    /// The input STAC value.
+    ///
+    /// If this is not provided, or is `-`, input will be read from standard
+    /// input.
+    pub infile: Option<String>,
+
+    /// The output STAC value.
+    ///
+    /// If not provided, output will be printed to standard output.
+    pub outfile: Option<String>,
+
+    /// The STAC version to migrate to.
+    #[arg(short, long, default_value_t = STAC_VERSION)]
+    pub version: Version,
+}
+
 impl Args {
     pub(crate) fn writer(&self) -> Result<Box<dyn Write + Send>> {
         if let Some(outfile) = self.subcommand.outfile() {
diff --git a/stac-cli/src/lib.rs b/stac-cli/src/lib.rs
index c82dcbc3..300b4de5 100644
--- a/stac-cli/src/lib.rs
+++ b/stac-cli/src/lib.rs
@@ -39,7 +39,9 @@ mod runner;
 mod subcommand;
 
 pub use {
-    args::{Args, ItemArgs, SearchArgs, ServeArgs, SortArgs, TranslateArgs, ValidateArgs},
+    args::{
+        Args, ItemArgs, MigrateArgs, SearchArgs, ServeArgs, SortArgs, TranslateArgs, ValidateArgs,
+    },
     error::Error,
     format::Format,
     output::Output,
diff --git a/stac-cli/src/subcommand/migrate.rs b/stac-cli/src/subcommand/migrate.rs
new file mode 100644
index 00000000..5e8a0734
--- /dev/null
+++ b/stac-cli/src/subcommand/migrate.rs
@@ -0,0 +1,10 @@
+use crate::{Error, Format, MigrateArgs, Result, Subcommand};
+use stac::{Migrate, Value};
+
+impl Subcommand {
+    /// Migrates a STAC value.
+    pub(crate) async fn migrate(args: MigrateArgs, input_format: Format) -> Result<Value> {
+        let value: Value = input_format.read_href(args.infile.as_deref()).await?;
+        value.migrate(args.version).map_err(Error::from)
+    }
+}
diff --git a/stac-cli/src/subcommand/mod.rs b/stac-cli/src/subcommand/mod.rs
index 9bcafc84..5c2ff97c 100644
--- a/stac-cli/src/subcommand/mod.rs
+++ b/stac-cli/src/subcommand/mod.rs
@@ -1,4 +1,5 @@
 mod item;
+mod migrate;
 mod search;
 mod serve;
 mod sort;
@@ -6,8 +7,8 @@ mod translate;
 mod validate;
 
 use crate::{
-    Error, Format, ItemArgs, Output, Result, SearchArgs, ServeArgs, SortArgs, TranslateArgs,
-    ValidateArgs,
+    Error, Format, ItemArgs, MigrateArgs, Output, Result, SearchArgs, ServeArgs, SortArgs,
+    TranslateArgs, ValidateArgs,
 };
 use tokio::sync::mpsc::Sender;
 
@@ -18,6 +19,9 @@ pub enum Subcommand {
     /// Creates a STAC Item.
     Item(ItemArgs),
 
+    /// Migrates a STAC value from one version to another.
+    Migrate(MigrateArgs),
+
     /// Searches a STAC API.
     Search(SearchArgs),
 
@@ -71,6 +75,10 @@ impl Subcommand {
                 let item = Subcommand::item(args)?;
                 sender.send(item.into()).await?;
             }
+            Migrate(args) => {
+                let value = Subcommand::migrate(args, input_format).await?;
+                sender.send(value.into()).await?;
+            }
             Search(args) => Subcommand::search(args, sender).await?,
             Serve(args) => Subcommand::serve(args, sender).await?,
             Sort(args) => {
diff --git a/stac/CHANGELOG.md b/stac/CHANGELOG.md
index 52b54e48..5af247a5 100644
--- a/stac/CHANGELOG.md
+++ b/stac/CHANGELOG.md
@@ -9,11 +9,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ### Added
 
 - `Version` enum ([#293](https://github.com/stac-utils/stac-rs/pull/293))
+- STAC v1.1.0-beta.1 ([#294](https://github.com/stac-utils/stac-rs/pull/294)):
+  - `Asset::bands` vec and `Band` struct
+  - `nodata`, `data_type`, `statistics`, and `unit` to `Asset`
+- Migrations via a `Migrate` trait ([#294](https://github.com/stac-utils/stac-rs/pull/294))
 
 ### Changed
 
 - `STAC_VERSION` is now a `Version`, not a string ([#293](https://github.com/stac-utils/stac-rs/pull/293))
 - `version` fields on collection, catalog, and item are now public ([#293](https://github.com/stac-utils/stac-rs/pull/293))
+- `DataType` and `Statistics` structures are now top-level instead of being in the `raster` extension ([#294](https://github.com/stac-utils/stac-rs/pull/294))
 
 ## [0.7.2] - 2024-07-24
 
diff --git a/stac/Cargo.toml b/stac/Cargo.toml
index 816cb0ab..2ac6e15e 100644
--- a/stac/Cargo.toml
+++ b/stac/Cargo.toml
@@ -30,6 +30,7 @@ url = "2"
 
 [dev-dependencies]
 assert-json-diff = "2"
+rstest = "0.22"
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/stac/examples/bands-v1.0.0.json b/stac/examples/bands-v1.0.0.json
new file mode 100644
index 00000000..874cc2ca
--- /dev/null
+++ b/stac/examples/bands-v1.0.0.json
@@ -0,0 +1,55 @@
+{
+    "type": "Feature",
+    "stac_version": "1.0.0",
+    "id": "bands-migration",
+    "geometry": null,
+    "properties": {
+        "datetime": "2024-08-11T16:07:32.766181+00:00"
+    },
+    "links": [],
+    "assets": {
+        "example": {
+            "href": "example.tif",
+            "eo:bands": [
+                {
+                    "name": "r",
+                    "common_name": "red"
+                },
+                {
+                    "name": "g",
+                    "common_name": "green"
+                },
+                {
+                    "name": "b",
+                    "common_name": "blue"
+                },
+                {
+                    "name": "nir",
+                    "common_name": "nir"
+                }
+            ],
+            "raster:bands": [
+                {
+                    "data_type": "uint16",
+                    "spatial_resolution": 10,
+                    "sampling": "area"
+                },
+                {
+                    "data_type": "uint16",
+                    "spatial_resolution": 10,
+                    "sampling": "area"
+                },
+                {
+                    "data_type": "uint16",
+                    "spatial_resolution": 10,
+                    "sampling": "area"
+                },
+                {
+                    "data_type": "uint16",
+                    "spatial_resolution": 30,
+                    "sampling": "area"
+                }
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/stac/examples/bands-v1.1.0-beta.1.json b/stac/examples/bands-v1.1.0-beta.1.json
new file mode 100644
index 00000000..f033d490
--- /dev/null
+++ b/stac/examples/bands-v1.1.0-beta.1.json
@@ -0,0 +1,37 @@
+{
+    "type": "Feature",
+    "stac_version": "1.1.0-beta.1",
+    "id": "bands-migration",
+    "geometry": null,
+    "properties": {
+        "datetime": "2024-08-11T16:07:32.766181+00:00"
+    },
+    "links": [],
+    "assets": {
+        "example": {
+            "href": "example.tif",
+            "data_type": "uint16",
+            "raster:sampling": "area",
+            "raster:spatial_resolution": 10,
+            "bands": [
+                {
+                    "name": "r",
+                    "eo:common_name": "red"
+                },
+                {
+                    "name": "g",
+                    "eo:common_name": "green"
+                },
+                {
+                    "name": "b",
+                    "eo:common_name": "blue"
+                },
+                {
+                    "name": "nir",
+                    "eo:common_name": "nir",
+                    "raster:spatial_resolution": 30
+                }
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/stac/src/asset.rs b/stac/src/asset.rs
index 3bcaa10a..38dd6c43 100644
--- a/stac/src/asset.rs
+++ b/stac/src/asset.rs
@@ -1,4 +1,4 @@
-use crate::{Extensions, Fields};
+use crate::{Band, DataType, Extensions, Fields, Statistics};
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 use std::collections::HashMap;
@@ -52,6 +52,31 @@ pub struct Asset {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub updated: Option<String>,
 
+    /// An array of available bands where each object is a [Band].
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub bands: Vec<Band>,
+
+    /// Value used to identify no-data.
+    ///
+    /// The extension specifies that this can be a number or a string, but we
+    /// just use a f64 with a custom (de)serializer.
+    ///
+    /// TODO write custom (de)serializer.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub nodata: Option<f64>,
+
+    /// The data type of the values.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub data_type: Option<DataType>,
+
+    /// Statistics of all the values.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub statistics: Option<Statistics>,
+
+    /// Unit of measurement of the value.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub unit: Option<String>,
+
     /// Additional fields on the asset.
     #[serde(flatten)]
     pub additional_fields: Map<String, Value>,
@@ -110,6 +135,11 @@ impl Asset {
             roles: Vec::new(),
             created: None,
             updated: None,
+            bands: Vec::new(),
+            data_type: None,
+            nodata: None,
+            statistics: None,
+            unit: None,
             additional_fields: Map::new(),
             extensions: Vec::new(),
         }
diff --git a/stac/src/band.rs b/stac/src/band.rs
new file mode 100644
index 00000000..40e1834a
--- /dev/null
+++ b/stac/src/band.rs
@@ -0,0 +1,55 @@
+use crate::{DataType, Statistics};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+/// Bands are used to describe the available bands in a STAC entity or Asset.
+///
+/// A band describes the general construct of a band or layer, which doesn't
+/// necessarily need to be a spectral band. By adding fields from extensions you
+/// can indicate that a band, for example, is
+///
+/// - a spectral band (EO extension),
+/// - a band with classification results (classification extension),
+/// - a band with quality information such as cloud cover probabilities,
+///
+/// etc.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Band {
+    /// The name of the band (e.g., "B01", "B8", "band2", "red"), which should
+    /// be unique across all bands defined in the list of bands.
+    ///
+    /// This is typically the name the data provider uses for the band.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub name: Option<String>,
+
+    /// Description to fully explain the band.
+    ///
+    /// CommonMark 0.29 syntax MAY be used for rich text representation.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option<String>,
+
+    /// Value used to identify no-data.
+    ///
+    /// The extension specifies that this can be a number or a string, but we
+    /// just use a f64 with a custom (de)serializer.
+    ///
+    /// TODO write custom (de)serializer.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub nodata: Option<f64>,
+
+    /// The data type of the values.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub data_type: Option<DataType>,
+
+    /// Statistics of all the values.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub statistics: Option<Statistics>,
+
+    /// Unit of measurement of the value.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub unit: Option<String>,
+
+    /// Additional fields on the asset.
+    #[serde(flatten)]
+    pub additional_fields: Map<String, Value>,
+}
diff --git a/stac/src/catalog.rs b/stac/src/catalog.rs
index a8ca2006..90fdba02 100644
--- a/stac/src/catalog.rs
+++ b/stac/src/catalog.rs
@@ -1,4 +1,4 @@
-use crate::{Error, Extensions, Fields, Href, Link, Links, Result, Version, STAC_VERSION};
+use crate::{Error, Extensions, Fields, Href, Link, Links, Migrate, Result, Version, STAC_VERSION};
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 
@@ -154,6 +154,8 @@ where
     crate::serialize_type(r#type, serializer, CATALOG_TYPE)
 }
 
+impl Migrate for Catalog {}
+
 #[cfg(test)]
 mod tests {
     use super::Catalog;
diff --git a/stac/src/collection.rs b/stac/src/collection.rs
index 098e7ce8..04524f3d 100644
--- a/stac/src/collection.rs
+++ b/stac/src/collection.rs
@@ -1,5 +1,6 @@
 use crate::{
-    Asset, Assets, Error, Extensions, Fields, Href, Link, Links, Result, Version, STAC_VERSION,
+    Asset, Assets, Error, Extensions, Fields, Href, Link, Links, Migrate, Result, Version,
+    STAC_VERSION,
 };
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
@@ -301,6 +302,8 @@ where
     crate::serialize_type(r#type, serializer, COLLECTION_TYPE)
 }
 
+impl Migrate for Collection {}
+
 #[cfg(test)]
 mod tests {
     use super::{Collection, Extent, Provider};
diff --git a/stac/src/data_type.rs b/stac/src/data_type.rs
new file mode 100644
index 00000000..ef0fc961
--- /dev/null
+++ b/stac/src/data_type.rs
@@ -0,0 +1,54 @@
+use serde::{Deserialize, Serialize};
+
+/// The data type gives information about the values in the file.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum DataType {
+    /// 8-bit integer
+    Int8,
+
+    /// 16-bit integer
+    Int16,
+
+    /// 32-bit integer
+    Int32,
+
+    /// 64-bit integer
+    Int64,
+
+    /// Unsigned 8-bit integer (common for 8-bit RGB PNG's)
+    UInt8,
+
+    /// Unsigned 16-bit integer
+    UInt16,
+
+    /// Unsigned 32-bit integer
+    UInt32,
+
+    /// Unsigned 64-bit integer
+    UInt64,
+
+    /// 16-bit float
+    Float16,
+
+    /// 32-bit float
+    Float32,
+
+    /// 64-bit float
+    Float64,
+
+    /// 16-bit complex integer
+    CInt16,
+
+    /// 32-bit complex integer
+    CInt32,
+
+    /// 32-bit complex float
+    CFloat32,
+
+    /// 64-bit complex float
+    CFloat64,
+
+    /// Other data type than the ones listed above (e.g. boolean, string, higher precision numbers)
+    Other,
+}
diff --git a/stac/src/error.rs b/stac/src/error.rs
index 96173560..15aa1b4b 100644
--- a/stac/src/error.rs
+++ b/stac/src/error.rs
@@ -1,4 +1,4 @@
-use crate::Value;
+use crate::{Value, Version};
 use serde_json::Value as JsonValue;
 use thiserror::Error;
 
@@ -106,6 +106,10 @@ pub enum Error {
     #[error("unsupported version: {0}")]
     UnsupportedVersion(String),
 
+    /// Unsupported migration.
+    #[error("unsupported migration: {0} to {1}")]
+    UnsupportedMigration(Version, Version),
+
     /// [url::ParseError]
     #[error(transparent)]
     Url(#[from] url::ParseError),
diff --git a/stac/src/extensions/raster.rs b/stac/src/extensions/raster.rs
index 73a58c08..fa8d6753 100644
--- a/stac/src/extensions/raster.rs
+++ b/stac/src/extensions/raster.rs
@@ -9,9 +9,9 @@
 //! many applications, it is interesting to have some metadata about the rasters
 //! in the asset (values statistics, value interpretation, transforms).
 
-use serde::{Deserialize, Serialize};
-
 use super::Extension;
+pub use crate::{DataType, Statistics};
+use serde::{Deserialize, Serialize};
 
 /// The raster extension.
 #[derive(Debug, Serialize, Deserialize, Default)]
@@ -94,83 +94,6 @@ pub enum Sampling {
     Point,
 }
 
-/// The data type gives information about the values in the file.
-#[derive(Debug, Serialize, Deserialize, PartialEq)]
-#[serde(rename_all = "lowercase")]
-pub enum DataType {
-    /// 8-bit integer
-    Int8,
-
-    /// 16-bit integer
-    Int16,
-
-    /// 32-bit integer
-    Int32,
-
-    /// 64-bit integer
-    Int64,
-
-    /// Unsigned 8-bit integer (common for 8-bit RGB PNG's)
-    UInt8,
-
-    /// Unsigned 16-bit integer
-    UInt16,
-
-    /// Unsigned 32-bit integer
-    UInt32,
-
-    /// Unsigned 64-bit integer
-    UInt64,
-
-    /// 16-bit float
-    Float16,
-
-    /// 32-bit float
-    Float32,
-
-    /// 64-bit float
-    Float64,
-
-    /// 16-bit complex integer
-    CInt16,
-
-    /// 32-bit complex integer
-    CInt32,
-
-    /// 32-bit complex float
-    CFloat32,
-
-    /// 64-bit complex float
-    CFloat64,
-
-    /// Other data type than the ones listed above (e.g. boolean, string, higher precision numbers)
-    Other,
-}
-
-/// Statistics of all pixels in the band.
-#[derive(Debug, Serialize, Deserialize)]
-pub struct Statistics {
-    /// Mean value of all the pixels in the band
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub mean: Option<f64>,
-
-    /// Minimum value of all the pixels in the band
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub minimum: Option<f64>,
-
-    /// Maximum value of all the pixels in the band
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub maximum: Option<f64>,
-
-    /// Standard deviation value of all the pixels in the band
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub stddev: Option<f64>,
-
-    /// Percentage of valid (not nodata) pixel
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub valid_percent: Option<f64>,
-}
-
 /// The distribution of pixel values of a band can be provided with a histogram
 /// object.
 ///
diff --git a/stac/src/item.rs b/stac/src/item.rs
index c4eb053b..7bbacb9b 100644
--- a/stac/src/item.rs
+++ b/stac/src/item.rs
@@ -1,7 +1,8 @@
 //! STAC Items.
 
 use crate::{
-    Asset, Assets, Error, Extensions, Fields, Href, Link, Links, Result, Version, STAC_VERSION,
+    Asset, Assets, Error, Extensions, Fields, Href, Link, Links, Migrate, Result, Version,
+    STAC_VERSION,
 };
 use chrono::{DateTime, FixedOffset, Utc};
 use geojson::{feature::Id, Feature, Geometry};
@@ -779,6 +780,8 @@ fn default_type() -> String {
     ITEM_TYPE.to_string()
 }
 
+impl Migrate for Item {}
+
 #[cfg(test)]
 mod tests {
     use super::{Builder, FlatItem, Item};
diff --git a/stac/src/item_collection.rs b/stac/src/item_collection.rs
index fdb4b6c1..0c90c2ce 100644
--- a/stac/src/item_collection.rs
+++ b/stac/src/item_collection.rs
@@ -1,4 +1,4 @@
-use crate::{Href, Item, Link, Links};
+use crate::{Href, Item, Link, Links, Migrate};
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 
@@ -88,6 +88,17 @@ where
     crate::serialize_type(r#type, serializer, ITEM_COLLECTION_TYPE)
 }
 
+impl Migrate for ItemCollection {
+    fn migrate(mut self, version: crate::Version) -> crate::Result<Self> {
+        let mut items = Vec::with_capacity(self.items.len());
+        for item in self.items {
+            items.push(item.migrate(version)?);
+        }
+        self.items = items;
+        Ok(self)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::ItemCollection;
diff --git a/stac/src/lib.rs b/stac/src/lib.rs
index 838ec61f..688f9305 100644
--- a/stac/src/lib.rs
+++ b/stac/src/lib.rs
@@ -116,9 +116,11 @@
 )]
 
 mod asset;
+mod band;
 mod bounds;
 mod catalog;
 mod collection;
+mod data_type;
 pub mod datetime;
 mod error;
 pub mod extensions;
@@ -133,14 +135,18 @@ pub mod item;
 mod item_collection;
 pub mod link;
 pub mod media_type;
+mod migrate;
+mod statistics;
 mod value;
 mod version;
 
 pub use {
     asset::{Asset, Assets},
+    band::Band,
     bounds::Bounds,
     catalog::{Catalog, CATALOG_TYPE},
     collection::{Collection, Extent, Provider, SpatialExtent, TemporalExtent, COLLECTION_TYPE},
+    data_type::DataType,
     error::Error,
     extensions::{Extension, Extensions},
     fields::Fields,
@@ -149,6 +155,8 @@ pub use {
     item::{FlatItem, Item, Properties, ITEM_TYPE},
     item_collection::{ItemCollection, ITEM_COLLECTION_TYPE},
     link::{Link, Links},
+    migrate::Migrate,
+    statistics::Statistics,
     value::Value,
     version::Version,
 };
@@ -207,6 +215,8 @@ where
 
 #[cfg(test)]
 mod tests {
+    use rstest as _;
+
     macro_rules! roundtrip {
         ($function:ident, $filename:expr, $object:ident) => {
             #[test]
diff --git a/stac/src/migrate.rs b/stac/src/migrate.rs
new file mode 100644
index 00000000..efa15a62
--- /dev/null
+++ b/stac/src/migrate.rs
@@ -0,0 +1,236 @@
+use std::collections::HashMap;
+
+use crate::{Error, Result, Version};
+use serde::{de::DeserializeOwned, Serialize};
+use serde_json::{Map, Value};
+
+/// Migrates a STAC object from one version to another.
+pub trait Migrate: Sized + Serialize + DeserializeOwned {
+    /// Migrates this object to another version.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac::{Item, Migrate, Version};
+    ///
+    /// let mut item: Item = stac::read("../spec-examples/v1.0.0/simple-item.json").unwrap();
+    /// let item = item.migrate(Version::v1_1_0_beta_1).unwrap();
+    /// assert_eq!(item.version, Version::v1_1_0_beta_1);
+    /// ```
+    fn migrate(self, to: Version) -> Result<Self> {
+        let mut value = serde_json::to_value(self)?;
+        if let Some(version) = value
+            .as_object()
+            .and_then(|object| object.get("stac_version"))
+            .and_then(|version| version.as_str())
+        {
+            let from: Version = version.parse()?;
+            let steps = from.steps(to)?;
+            for step in steps {
+                value = step.migrate(value)?;
+            }
+            let _ = value
+                .as_object_mut()
+                .unwrap()
+                .insert("stac_version".into(), to.to_string().into());
+        } else {
+            log::warn!("no stac_version attribute found, skipping any migrations");
+        }
+        serde_json::from_value(value).map_err(Error::from)
+    }
+}
+
+#[allow(non_camel_case_types)]
+enum Step {
+    v1_0_0_to_v1_1_0_beta_1,
+}
+
+impl Version {
+    fn steps(self, to: Version) -> Result<Vec<Step>> {
+        match self {
+            Version::v1_0_0 => match to {
+                Version::v1_0_0 => Ok(Vec::new()),
+                Version::v1_1_0_beta_1 => Ok(vec![Step::v1_0_0_to_v1_1_0_beta_1]),
+            },
+            Version::v1_1_0_beta_1 => match to {
+                Version::v1_0_0 => Err(Error::UnsupportedMigration(self, to)),
+                Version::v1_1_0_beta_1 => Ok(Vec::new()),
+            },
+        }
+    }
+}
+
+impl Step {
+    fn migrate(&self, mut value: Value) -> Result<Value> {
+        if let Some(mut object) = value.as_object_mut() {
+            match self {
+                Step::v1_0_0_to_v1_1_0_beta_1 => {
+                    if let Some(assets) = object.get_mut("assets").and_then(|v| v.as_object_mut()) {
+                        for asset in assets.values_mut().filter_map(|v| v.as_object_mut()) {
+                            migrate_bands(asset)?;
+                        }
+                    }
+                    migrate_links(object);
+                    if object
+                        .get("type")
+                        .and_then(|t| t.as_str())
+                        .map(|t| t == "Feature")
+                        .unwrap_or_default()
+                    {
+                        if object
+                            .get("properties")
+                            .and_then(|p| p.as_object())
+                            .is_none()
+                        {
+                            let _ = object.insert(
+                                "properties".to_string(),
+                                Value::Object(Default::default()),
+                            );
+                        }
+                        object = object
+                            .get_mut("properties")
+                            .and_then(|v| v.as_object_mut())
+                            .unwrap();
+                    }
+                    migrate_license(object);
+                }
+            }
+        }
+        Ok(value)
+    }
+}
+
+fn migrate_bands(asset: &mut Map<String, Value>) -> Result<()> {
+    let mut bands: Vec<Map<String, Value>> = Vec::new();
+    if let Some(Value::Array(eo)) = asset.remove("eo:bands") {
+        bands.resize_with(eo.len(), Default::default);
+        for (eo_band, band) in eo.into_iter().zip(bands.iter_mut()) {
+            if let Value::Object(eo_band) = eo_band {
+                for (key, value) in eo_band.into_iter() {
+                    if key == "name" {
+                        let _ = band.insert(key, value);
+                    } else {
+                        let _ = band.insert(format!("eo:{}", key), value);
+                    }
+                }
+            }
+        }
+    }
+    if let Some(Value::Array(raster)) = asset.remove("raster:bands") {
+        if raster.len() > bands.len() {
+            bands.resize_with(raster.len(), Default::default);
+        }
+        for (raster_band, band) in raster.into_iter().zip(bands.iter_mut()) {
+            if let Value::Object(raster_band) = raster_band {
+                for (key, value) in raster_band.into_iter() {
+                    if key == "nodata" || key == "data_type" || key == "statistics" || key == "unit"
+                    {
+                        let _ = band.insert(key, value);
+                    } else {
+                        let _ = band.insert(format!("raster:{}", key), value);
+                    }
+                }
+            }
+        }
+    }
+    let mut counts: HashMap<String, HashMap<String, u64>> = HashMap::new();
+    let mut values: HashMap<String, Value> = HashMap::new();
+    for band in &bands {
+        for (key, value) in band {
+            let value_as_string = serde_json::to_string(value)?;
+            if !values.contains_key(&value_as_string) {
+                let _ = values.insert(value_as_string.clone(), value.clone());
+            }
+            *counts
+                .entry(key.to_string())
+                .or_default()
+                .entry(value_as_string)
+                .or_default() += 1;
+        }
+    }
+    for (key, count) in counts {
+        if let Some((value_as_string, &count)) = count.iter().max_by_key(|(_, &count)| count) {
+            if count > 1 {
+                let value = values
+                    .get(value_as_string)
+                    .expect("every value should be in the lookup hash")
+                    .clone();
+                for band in &mut bands {
+                    if band.get(&key).map(|v| v == &value).unwrap_or_default() {
+                        let value = band.remove(&key).unwrap();
+                        let _ = asset.insert(key.clone(), value);
+                    }
+                }
+            }
+        }
+    }
+    let _ = asset.insert(
+        "bands".into(),
+        Value::Array(bands.into_iter().map(Value::Object).collect()),
+    );
+    Ok(())
+}
+
+fn migrate_links(object: &mut Map<String, Value>) {
+    if let Some(links) = object.get_mut("links").and_then(|v| v.as_array_mut()) {
+        for link in links {
+            if let Some(link) = link.as_object_mut() {
+                if link
+                    .get("rel")
+                    .and_then(|v| v.as_str())
+                    .map(|s| s == "self")
+                    .unwrap_or_default()
+                {
+                    if let Some(href) = link.get("href").and_then(|v| v.as_str()) {
+                        if href.starts_with('/') {
+                            let _ =
+                                link.insert("href".to_string(), format!("file://{}", href).into());
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn migrate_license(object: &mut Map<String, Value>) {
+    if object
+        .get("license")
+        .and_then(|v| v.as_str())
+        .map(|l| l == "proprietary" || l == "various")
+        .unwrap_or_default()
+    {
+        let _ = object.insert("license".into(), "other".to_string().into());
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{Collection, DataType, Item, Link, Links, Migrate, Version};
+    use assert_json_diff::assert_json_eq;
+    use serde_json::Value;
+
+    #[test]
+    fn migrate_v1_0_0_to_v1_1_0_beta_1() {
+        let item: Item = crate::read("examples/bands-v1.0.0.json").unwrap();
+        let item = item.migrate(Version::v1_1_0_beta_1).unwrap();
+        let asset = &item.assets["example"];
+        assert_eq!(asset.data_type.as_ref().unwrap(), &DataType::UInt16);
+        assert_eq!(asset.bands[0].name.as_ref().unwrap(), "r");
+        assert_eq!(asset.bands[1].name.as_ref().unwrap(), "g");
+        assert_eq!(asset.bands[2].name.as_ref().unwrap(), "b");
+        assert_eq!(asset.bands[3].name.as_ref().unwrap(), "nir");
+
+        let expected: Value = crate::read_json("examples/bands-v1.1.0-beta.1.json").unwrap();
+        assert_json_eq!(expected, serde_json::to_value(item).unwrap());
+
+        let collection = Collection::new("an-id", "a description");
+        let collection = collection.migrate(Version::v1_1_0_beta_1).unwrap();
+        assert_eq!(collection.license, "other");
+
+        let mut item = Item::new("an-id");
+        item.set_link(Link::self_("/an/absolute/href"));
+        let item = item.migrate(Version::v1_1_0_beta_1).unwrap();
+        assert_eq!(item.link("self").unwrap().href, "file:///an/absolute/href");
+    }
+}
diff --git a/stac/src/statistics.rs b/stac/src/statistics.rs
new file mode 100644
index 00000000..6eeeee3b
--- /dev/null
+++ b/stac/src/statistics.rs
@@ -0,0 +1,25 @@
+use serde::{Deserialize, Serialize};
+
+/// Statistics of all pixels in the band.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Statistics {
+    /// Mean value of all the pixels in the band
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mean: Option<f64>,
+
+    /// Minimum value of all the pixels in the band
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub minimum: Option<f64>,
+
+    /// Maximum value of all the pixels in the band
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub maximum: Option<f64>,
+
+    /// Standard deviation value of all the pixels in the band
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub stddev: Option<f64>,
+
+    /// Percentage of valid (not nodata) pixel
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub valid_percent: Option<f64>,
+}
diff --git a/stac/src/value.rs b/stac/src/value.rs
index 0d6b1ab5..cc7dc3a2 100644
--- a/stac/src/value.rs
+++ b/stac/src/value.rs
@@ -1,4 +1,6 @@
-use crate::{Catalog, Collection, Error, Href, Item, ItemCollection, Link, Links, Result};
+use crate::{
+    Catalog, Collection, Error, Href, Item, ItemCollection, Link, Links, Migrate, Result, Version,
+};
 use serde::{Deserialize, Serialize};
 use serde_json::Map;
 use std::convert::TryFrom;
@@ -261,6 +263,19 @@ impl From<ItemCollection> for Value {
     }
 }
 
+impl Migrate for Value {
+    fn migrate(self, version: Version) -> Result<Value> {
+        match self {
+            Value::Item(item) => item.migrate(version).map(Value::Item),
+            Value::Catalog(catalog) => catalog.migrate(version).map(Value::Catalog),
+            Value::Collection(collection) => collection.migrate(version).map(Value::Collection),
+            Value::ItemCollection(item_collection) => {
+                item_collection.migrate(version).map(Value::ItemCollection)
+            }
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::Value;
diff --git a/stac/src/version.rs b/stac/src/version.rs
index e9570c98..646232e2 100644
--- a/stac/src/version.rs
+++ b/stac/src/version.rs
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
 use std::{fmt::Display, str::FromStr};
 
 /// A version of the STAC specification.
-#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Eq, Hash)]
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Eq, Hash, PartialOrd)]
 #[allow(non_camel_case_types)]
 pub enum Version {
     /// [v1.0.0](https://github.com/radiantearth/stac-spec/releases/tag/v1.0.0)


Test patch:
diff --git a/stac-validate/tests/migrate.rs b/stac-validate/tests/migrate.rs
new file mode 100644
index 00000000..0cf2b397
--- /dev/null
+++ b/stac-validate/tests/migrate.rs
@@ -0,0 +1,11 @@
+use rstest::rstest;
+use stac::{Migrate, Value, Version};
+use stac_validate::Validate;
+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();
+}
diff --git a/stac/tests/migrate.rs b/stac/tests/migrate.rs
new file mode 100644
index 00000000..caf4809b
--- /dev/null
+++ b/stac/tests/migrate.rs
@@ -0,0 +1,9 @@
+use rstest::rstest;
+use stac::{Migrate, Value, Version};
+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.migrate(Version::v1_1_0_beta_1).unwrap();
+}