diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b71ab..a2e668c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,8 +71,3 @@ jobs: testJobCreationInputRef ogc-api-features: true ogc-api-features-container-tag: 1.9-teamengine-6.0.0-RC2 - ogc-api-features-ignore: |- - validateFeaturesWithLimitResponse_NumberMatched - validateFeaturesResponse_NumberMatched - validateFeaturesWithBoundingBoxResponse_NumberMatched - validateFeaturesWithLimitResponse_NumberMatched diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab6df3..9e61c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed fields to status database model for OGC API - Processes. - Consolidate API definition for OGC cite validation. - Decouple drivers from app state. +- Set default item type of collection as `feature`. +- Define numeric feature id as `u64`. +- Remove default Crs implementation. ## [0.3.0] - 2025-04-05 diff --git a/examples/data-loader/src/geojson.rs b/examples/data-loader/src/geojson.rs index 00d4518..ab16ceb 100644 --- a/examples/data-loader/src/geojson.rs +++ b/examples/data-loader/src/geojson.rs @@ -25,7 +25,6 @@ pub async fn load(args: Args) -> anyhow::Result<()> { // Create collection let collection = Collection { id: args.collection.to_owned(), - item_type: Some("Feature".to_string()), extent: geojson .bbox .map(|bbox| Extent { @@ -35,13 +34,13 @@ pub async fn load(args: Args) -> anyhow::Result<()> { .try_into() .unwrap_or_else(|_| [-180.0, -90.0, 180.0, 90.0].into()), ], - crs: Crs::default(), + crs: Some(Crs::default2d()), }, ..Default::default() }) .or_else(|| Some(Extent::default())), - crs: vec![Crs::default(), Crs::from_epsg(3857)], - storage_crs: Some(Crs::default()), + crs: vec![Crs::default2d(), Crs::from_epsg(3857)], + storage_crs: Some(Crs::default2d()), #[cfg(feature = "stac")] assets: crate::asset::load_asset_from_path(&args.input).await?, ..Default::default() diff --git a/examples/data-loader/src/ogr.rs b/examples/data-loader/src/ogr.rs index fc9d85f..a61d42d 100644 --- a/examples/data-loader/src/ogr.rs +++ b/examples/data-loader/src/ogr.rs @@ -73,14 +73,14 @@ pub async fn load(mut args: Args) -> Result<(), anyhow::Error> { let collection = Collection { id: args.collection.to_owned(), crs: Vec::from_iter(HashSet::from([ - Crs::default(), + Crs::default2d(), storage_crs.clone(), Crs::from_epsg(3857), ])), extent: layer.try_get_extent()?.map(|e| Extent { spatial: SpatialExtent { bbox: vec![Bbox::Bbox2D([e.MinX, e.MinY, e.MaxX, e.MaxY])], - crs: storage_crs.to_owned(), + crs: Some(storage_crs.to_owned()), }, ..Default::default() }), diff --git a/examples/data-loader/src/osm.rs b/examples/data-loader/src/osm.rs index aa790dd..d494b14 100644 --- a/examples/data-loader/src/osm.rs +++ b/examples/data-loader/src/osm.rs @@ -20,7 +20,7 @@ pub async fn load(args: Args) -> Result<(), anyhow::Error> { // Create collection let collection = Collection { id: args.collection.to_owned(), - crs: vec![Crs::default()], + crs: vec![Crs::default2d()], ..Default::default() }; // db.delete_collection(&collection.id).await?; diff --git a/ogcapi-drivers/src/lib.rs b/ogcapi-drivers/src/lib.rs index 6a3a734..0a97a6f 100644 --- a/ogcapi-drivers/src/lib.rs +++ b/ogcapi-drivers/src/lib.rs @@ -50,21 +50,21 @@ pub trait FeatureTransactions: Send + Sync { async fn read_feature( &self, - collection: &str, + collection_id: &str, id: &str, crs: &Crs, ) -> anyhow::Result>; async fn update_feature(&self, feature: &Feature) -> anyhow::Result<()>; - async fn delete_feature(&self, collection: &str, id: &str) -> anyhow::Result<()>; + async fn delete_feature(&self, collection_id: &str, id: &str) -> anyhow::Result<()>; async fn list_items( &self, - collection: &str, + collection_id: &str, query: &FeatureQuery, ) -> anyhow::Result; - async fn queryables(&self, _collection: &str) -> anyhow::Result { + async fn queryables(&self, _collection_id: &str) -> anyhow::Result { // Default to nothing is queryable Ok(Queryables { queryables: Default::default(), @@ -89,7 +89,7 @@ pub trait EdrQuerier: Send + Sync { collection_id: &str, query_type: &QueryType, query: &EdrQuery, - ) -> anyhow::Result; + ) -> anyhow::Result<(FeatureCollection, Crs)>; } /// Trait for `Processes` jobs diff --git a/ogcapi-drivers/src/postgres/collection.rs b/ogcapi-drivers/src/postgres/collection.rs index 4eea50d..d5a3c3b 100644 --- a/ogcapi-drivers/src/postgres/collection.rs +++ b/ogcapi-drivers/src/postgres/collection.rs @@ -1,4 +1,4 @@ -use ogcapi_types::common::{Collection, Collections, Query}; +use ogcapi_types::common::{Collection, Collections, Crs, Query}; use crate::CollectionTransactions; @@ -47,9 +47,14 @@ impl CollectionTransactions for Db { .execute(&mut *tx) .await?; + let srid = collection + .storage_crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default2d().as_srid()); sqlx::query("SELECT UpdateGeometrySRID('items', $1, 'geom', $2)") .bind(&collection.id) - .bind(collection.storage_crs.clone().unwrap_or_default().as_srid()) + .bind(srid) .execute(&mut *tx) .await?; diff --git a/ogcapi-drivers/src/postgres/edr.rs b/ogcapi-drivers/src/postgres/edr.rs index e0049e2..055518a 100644 --- a/ogcapi-drivers/src/postgres/edr.rs +++ b/ogcapi-drivers/src/postgres/edr.rs @@ -1,6 +1,7 @@ use sqlx::types::Json; use ogcapi_types::{ + common::{Crs, Exception}, edr::{Query, QueryType}, features::{Feature, FeatureCollection}, }; @@ -16,27 +17,49 @@ impl EdrQuerier for Db { collection_id: &str, query_type: &QueryType, query: &Query, - ) -> anyhow::Result { - let srid: i32 = query.crs.as_srid(); - - let c = self.read_collection(collection_id).await?; - let storage_srid = c.unwrap().storage_crs.unwrap_or_default().as_srid(); + ) -> anyhow::Result<(FeatureCollection, Crs)> { + let collection = self.read_collection(collection_id).await?; + let storage_srid = match collection { + Some(collection) => match collection.storage_crs.map(|crs| crs.as_srid()) { + Some(srid) => srid, + None => { + sqlx::query_scalar(&format!( + "SELECT Find_SRID('items', '{collection_id}', 'geom')" + )) + .fetch_one(&self.pool) + .await? + } + }, + None => return Err(Exception::new_from_status(404).into()), + }; let mut geometry_type = query.coords.split('(').next().unwrap().to_uppercase(); geometry_type.retain(|c| !c.is_whitespace()); - let spatial_predicate = match &query_type { + let (spatial_predicate, srid) = match &query_type { QueryType::Position | QueryType::Area | QueryType::Trajectory => { if geometry_type.ends_with('Z') || geometry_type.ends_with('M') { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default3d().as_srid()); + let predicate = format!( "ST_3DIntersects(geom, ST_Transform(ST_GeomFromEWKT('SRID={};{}'), {}))", srid, query.coords, storage_srid - ) + ); + (predicate, srid) } else { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default2d().as_srid()); + let predicate = format!( "ST_Intersects(geom, ST_Transform(ST_GeomFromEWKT('SRID={};{}'), {}))", srid, query.coords, storage_srid - ) + ); + (predicate, srid) } } QueryType::Radius => { @@ -56,26 +79,49 @@ impl EdrQuerier for Db { .expect("Failed to parse & convert distance"); if geometry_type.ends_with('Z') || geometry_type.ends_with('M') { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default3d().as_srid()); + let predicate = format!( "ST_3DDWithin(geom, ST_Transform(ST_GeomFromEWKT('SRID={};{}'), {}))", srid, query.coords, storage_srid - ) + ); + (predicate, srid) } else { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default2d().as_srid()); + let predicate = format!( "ST_DWithin(ST_Transform(geom, 4326)::geography, ST_Transform(ST_GeomFromEWKT('SRID={};{}'), 4326)::geography, {}, false)", srid, query.coords, distance - ) + ); + (predicate, srid) } } QueryType::Cube => { let bbox: Vec<&str> = query.coords.split(',').collect(); if bbox.len() == 4 { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default2d().as_srid()); + let predicate = format!( "ST_Intersects(geom, ST_Transform(ST_MakeEnvelope({}, {}), {})", query.coords, srid, storage_srid - ) + ); + (predicate, srid) } else { - format!( + let srid: i32 = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default3d().as_srid()); + let predicate = format!( "ST_3DIntersects( geom, ST_Transform( @@ -87,7 +133,8 @@ impl EdrQuerier for Db { ) )", bbox[0], bbox[1], bbox[2], bbox[3], bbox[4], bbox[5], srid, storage_srid - ) + ); + (predicate, srid) } } qt => unimplemented!("{qt:?}"), @@ -142,6 +189,6 @@ impl EdrQuerier for Db { let mut fc = FeatureCollection::new(features); fc.number_matched = Some(number_matched); - Ok(fc) + Ok((fc, Crs::from_srid(srid))) } } diff --git a/ogcapi-drivers/src/postgres/feature.rs b/ogcapi-drivers/src/postgres/feature.rs index 9790a7c..08f9ba5 100644 --- a/ogcapi-drivers/src/postgres/feature.rs +++ b/ogcapi-drivers/src/postgres/feature.rs @@ -1,5 +1,5 @@ use ogcapi_types::{ - common::{Authority, Bbox, Crs, Datetime, IntervalDatetime}, + common::{Authority, Bbox, Crs, Datetime, Exception, IntervalDatetime}, features::{Feature, FeatureCollection, Query}, }; @@ -133,25 +133,39 @@ impl FeatureTransactions for Db { async fn list_items( &self, - collection: &str, + collection_id: &str, query: &Query, ) -> anyhow::Result { let mut where_conditions = vec!["TRUE".to_owned()]; // bbox if let Some(bbox) = query.bbox.as_ref() { + // crs + let bbox_crs = query.bbox_crs.clone().unwrap_or_else(|| match bbox { + Bbox::Bbox2D(_) => Crs::default2d(), + Bbox::Bbox3D(_) => Crs::default3d(), + }); + // coordinate system axis order (OGC and Postgis is lng, lat | EPSG is lat, lng) - let order = match query.bbox_crs.authority { + let order = match bbox_crs.authority { Authority::OGC => [0, 1, 2, 3], Authority::EPSG => [1, 0, 3, 2], }; - let c = self.read_collection(collection).await?; - let storage_srid = c - .expect("collection exists") - .storage_crs - .unwrap_or_default() - .as_srid(); + let collection = self.read_collection(collection_id).await?; + let storage_srid = match collection { + Some(collection) => match collection.storage_crs.map(|crs| crs.as_srid()) { + Some(srid) => srid, + None => { + sqlx::query_scalar(&format!( + "SELECT Find_SRID('items', '{collection_id}', 'geom')" + )) + .fetch_one(&self.pool) + .await? + } + }, + None => return Err(Exception::new_from_status(404).into()), + }; // TODO: handle antimeridian (lower > upper on axis 1) let intersection = match bbox { @@ -161,7 +175,7 @@ impl FeatureTransactions for Db { bbox[order[1]], bbox[order[2]], bbox[order[3]], - query.bbox_crs.as_srid() + bbox_crs.as_srid() ), Bbox::Bbox3D(bbox) => format!( // FIXME: ensure proper height/box transformation handling @@ -181,7 +195,7 @@ impl FeatureTransactions for Db { x2 = bbox[order[2] + 1], y2 = bbox[order[3] + 1], z2 = bbox[5], - srid = query.bbox_crs.as_srid() + srid = bbox_crs.as_srid() ), }; @@ -260,20 +274,27 @@ impl FeatureTransactions for Db { // count let number_matched: (i64,) = sqlx::query_as(&format!( r#" - SELECT count(*) FROM items."{collection}" + SELECT count(*) FROM items."{collection_id}" WHERE {conditions} "#, )) .fetch_one(&self.pool) .await?; + // srid + let srid = query + .crs + .as_ref() + .map(|crs| crs.as_srid()) + .unwrap_or_else(|| Crs::default2d().as_srid()); + // fetch let features: Option>> = sqlx::query_scalar(&format!( r#" SELECT array_to_json(array_agg(row_to_json(t))) FROM ( SELECT {ROWS} - FROM items."{collection}" items JOIN meta.collections meta + FROM items."{collection_id}" items JOIN meta.collections meta ON items.collection = meta.id WHERE {conditions} LIMIT {} @@ -285,7 +306,7 @@ impl FeatureTransactions for Db { .map_or_else(|| String::from("NULL"), |l| l.to_string()), query.offset.unwrap_or(0) )) - .bind(query.crs.as_srid()) + .bind(srid) .fetch_one(&self.pool) .await?; diff --git a/ogcapi-drivers/src/postgres/tile.rs b/ogcapi-drivers/src/postgres/tile.rs index 2e783e9..cd9953f 100644 --- a/ogcapi-drivers/src/postgres/tile.rs +++ b/ogcapi-drivers/src/postgres/tile.rs @@ -16,19 +16,28 @@ impl TileTransactions for Db { ) -> anyhow::Result> { let mut sql: Vec = Vec::new(); - for collection in collections { - if let Some(c) = self.read_collection(collection).await? { - let storage_srid = c.storage_crs.unwrap_or_default().as_srid(); + for collection_id in collections { + if let Some(collection) = self.read_collection(collection_id).await? { + let storage_srid = match collection.storage_crs.map(|crs| crs.as_srid()) { + Some(srid) => srid, + None => { + sqlx::query_scalar(&format!( + "SELECT Find_SRID('items', '{collection_id}', 'geom')" + )) + .fetch_one(&self.pool) + .await? + } + }; sql.push(format!( r#" - SELECT ST_AsMVT(mvtgeom, '{collection}', 4096, 'geom') + SELECT ST_AsMVT(mvtgeom, '{collection_id}', 4096, 'geom') FROM ( SELECT ST_AsMVTGeom(ST_Transform(ST_Force2D(geom), 3857), ST_TileEnvelope($1, $3, $2), 4096, 64, TRUE) AS geom, - '{collection}' as collection, + '{collection_id}' as collection, properties - FROM items.{collection} + FROM items.{collection_id} WHERE geom && ST_Transform(ST_TileEnvelope($1, $3, $2, margin => (64.0 / 4096)), {storage_srid}) ) AS mvtgeom "# diff --git a/ogcapi-services/src/error.rs b/ogcapi-services/src/error.rs index aa33130..1b1fdf9 100644 --- a/ogcapi-services/src/error.rs +++ b/ogcapi-services/src/error.rs @@ -4,7 +4,6 @@ use axum::{ response::{IntoResponse, Response}, }; use hyper::HeaderMap; -use tracing::error; use ogcapi_types::common::{Exception, media_type::PROBLEM_JSON}; diff --git a/ogcapi-services/src/routes/collections.rs b/ogcapi-services/src/routes/collections.rs index 4464ff5..eb41438 100755 --- a/ogcapi-services/src/routes/collections.rs +++ b/ogcapi-services/src/routes/collections.rs @@ -288,7 +288,7 @@ async fn collections( Link::new(url.join(".")?, ROOT).mediatype(JSON), ]; - collections.crs = vec![Crs::default(), Crs::from_epsg(3857)]; + collections.crs = vec![Crs::default2d(), Crs::from_epsg(3857)]; Ok(Json(collections)) } diff --git a/ogcapi-services/src/routes/edr.rs b/ogcapi-services/src/routes/edr.rs index 4f18222..d6abf03 100644 --- a/ogcapi-services/src/routes/edr.rs +++ b/ogcapi-services/src/routes/edr.rs @@ -17,7 +17,7 @@ use crate::{ extractors::{Qs, RemoteUrl}, }; -const CONFORMANCE: [&str; 5] = [ +const CONFORMANCE: [&str; 6] = [ "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core", "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/collections", "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/json", @@ -25,7 +25,7 @@ const CONFORMANCE: [&str; 5] = [ // "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/edr-geojson", // "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/covjson", // "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/html", - // "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/oas30", + "http://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/oas30", "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/queries", ]; @@ -72,7 +72,7 @@ async fn query( ) -> Result<(HeaderMap, Json)> { tracing::debug!("{:#?}", query); - let mut fc = state + let (mut fc, crs) = state .drivers .edr .query(&collection_id, &query_type, &query) @@ -92,7 +92,7 @@ async fn query( } let mut headers = HeaderMap::new(); - headers.insert("Content-Crs", query.crs.to_string().parse().unwrap()); + headers.insert("Content-Crs", crs.to_string().parse().unwrap()); headers.insert(CONTENT_TYPE, GEO_JSON.parse().unwrap()); Ok((headers, Json(fc))) diff --git a/ogcapi-services/src/routes/features.rs b/ogcapi-services/src/routes/features.rs index 072ee68..aa976ce 100755 --- a/ogcapi-services/src/routes/features.rs +++ b/ogcapi-services/src/routes/features.rs @@ -109,12 +109,18 @@ async fn read( .read_collection(&collection_id) .await? .ok_or(Error::NotFound)?; - is_supported_crs(&collection, &query.crs).await?; + + let crs = if let Some(crs) = query.crs { + is_supported_crs(&collection, &crs).await?; + crs + } else { + Crs::default2d() + }; let mut feature = state .drivers .features - .read_feature(&collection_id, &id, &query.crs) + .read_feature(&collection_id, &id, &crs) .await? .ok_or(Error::NotFound)?; @@ -128,7 +134,7 @@ async fn read( let mut headers = HeaderMap::new(); headers.insert( "Content-Crs", - format!("<{}>", query.crs) + format!("<{}>", crs) .parse() .context("Unable to parse `Content-Crs` header value")?, ); @@ -257,7 +263,7 @@ async fn items( ) -> Result<(HeaderMap, Json)> { tracing::debug!("{:#?}", query); - // Limit + // limit if let Some(limit) = query.limit { // TODO: sync with opanapi specification if limit > 10000 { @@ -270,15 +276,26 @@ async fn items( query.limit = Some(10); } + // offset + if query.offset.is_none() { + query.offset = Some(0); + } + + // crs let collection = state .drivers .collections .read_collection(&collection_id) .await? .ok_or(Error::NotFound)?; - is_supported_crs(&collection, &query.crs).await?; + let crs = if let Some(crs) = query.crs.as_ref() { + is_supported_crs(&collection, crs).await?; + crs + } else { + &Crs::default2d() + }; - // validate additional parameters + // queryables let queryables = state.drivers.features.queryables(&collection_id).await?; if !queryables.additional_properties { for prop in query.additional_parameters.keys() { @@ -304,27 +321,32 @@ async fn items( ]); // pagination - if let Some(limit) = query.limit { - if query.offset.is_none() { - query.offset = Some(0); + if let Some(limit) = query.limit + && let Some(offset) = query.offset + { + if offset != 0 && offset >= limit { + query.offset = Some(offset - limit); + url.set_query(serde_qs::to_string(&query).ok().as_deref()); + let previous = Link::new(&url, PREV).mediatype(GEO_JSON); + fc.links.insert_or_update(&[previous]); } - if let Some(offset) = query.offset { - if offset != 0 && offset >= limit { - query.offset = Some(offset - limit); - url.set_query(serde_qs::to_string(&query).ok().as_deref()); - let previous = Link::new(&url, PREV).mediatype(GEO_JSON); - fc.links.insert_or_update(&[previous]); - } - - if let Some(number_matched) = fc.number_matched - && number_matched > (offset + limit) as u64 - { - query.offset = Some(offset + limit); - url.set_query(serde_qs::to_string(&query).ok().as_deref()); - let next = Link::new(&url, NEXT).mediatype(GEO_JSON); - fc.links.insert_or_update(&[next]); - } + let mut next = false; + if let Some(number_matched) = fc.number_matched + && number_matched > (offset + limit) as u64 + { + next = true; + } + if let Some(number_returned) = fc.number_returned + && number_returned == limit as u64 + { + next = true; + } + if next { + query.offset = Some(offset + limit); + url.set_query(serde_qs::to_string(&query).ok().as_deref()); + let next = Link::new(&url, NEXT).mediatype(GEO_JSON); + fc.links.insert_or_update(&[next]); } } @@ -336,12 +358,12 @@ async fn items( ) .mediatype(GEO_JSON), Link::new(url.join("../..")?, ROOT).mediatype(JSON), - Link::new(url.join(&format!("../{}", collection.id))?, COLLECTION).mediatype(JSON), + Link::new(url.join(&format!("../{}", collection_id))?, COLLECTION).mediatype(JSON), ]) } let mut headers = HeaderMap::new(); - headers.insert("Content-Crs", format!("<{}>", query.crs).parse().unwrap()); + headers.insert("Content-Crs", format!("<{}>", crs).parse().unwrap()); headers.insert(CONTENT_TYPE, GEO_JSON.parse().unwrap()); Ok((headers, Json(fc))) diff --git a/ogcapi-services/src/routes/tiles.rs b/ogcapi-services/src/routes/tiles.rs index 319824e..014dc39 100644 --- a/ogcapi-services/src/routes/tiles.rs +++ b/ogcapi-services/src/routes/tiles.rs @@ -172,7 +172,7 @@ async fn tile_set() -> Result> { data_type: DataType::Vector, tile_matrix_set_uri: Default::default(), tile_matrix_set_limits: Default::default(), - crs: TilesCrs::Simple(Crs::default().to_string()), + crs: TilesCrs::Simple(Crs::default2d().to_string()), epoch: Default::default(), links: Default::default(), layers: Default::default(), diff --git a/ogcapi-services/tests/crud.rs b/ogcapi-services/tests/crud.rs index f2bcc68..275eac8 100644 --- a/ogcapi-services/tests/crud.rs +++ b/ogcapi-services/tests/crud.rs @@ -23,7 +23,7 @@ async fn minimal_feature_crud() -> anyhow::Result<()> { let collection = Collection { id: "test.me-_".to_string(), links: vec![], - crs: vec![Crs::default()], + crs: vec![Crs::default2d()], ..Default::default() }; diff --git a/ogcapi-services/tests/edr.rs b/ogcapi-services/tests/edr.rs index dbb2ec7..696f3ee 100644 --- a/ogcapi-services/tests/edr.rs +++ b/ogcapi-services/tests/edr.rs @@ -46,7 +46,7 @@ async fn edr() -> anyhow::Result<()> { let query = Query { coords: "POINT(2600000 1200000)".to_string(), parameter_name: Some("NAME,ISO_A2,CONTINENT".to_string()), - crs: Crs::from_epsg(2056), + crs: Some(Crs::from_epsg(2056)), ..Default::default() }; diff --git a/ogcapi-types/src/common/collection.rs b/ogcapi-types/src/common/collection.rs index 22fb168..a8b9ac2 100644 --- a/ogcapi-types/src/common/collection.rs +++ b/ogcapi-types/src/common/collection.rs @@ -26,7 +26,8 @@ pub struct Collection { pub attribution: Option, pub extent: Option, /// An indicator about the type of the items in the collection. - pub item_type: Option, + #[serde(default = "feature")] + pub item_type: String, /// The list of coordinate reference systems supported by the API; the first item is the default coordinate reference system. #[serde(default)] #[serde_as(as = "Vec")] @@ -99,6 +100,10 @@ pub struct Collection { pub additional_properties: Map, } +fn feature() -> String { + "feature".to_string() +} + #[cfg(feature = "stac")] fn collection() -> String { "Collection".to_string() @@ -117,7 +122,7 @@ impl Default for Collection { attribution: Default::default(), extent: Default::default(), item_type: Default::default(), - crs: vec![Crs::default()], + crs: vec![Crs::default2d()], storage_crs: Default::default(), storage_crs_coordinate_epoch: Default::default(), links: Default::default(), diff --git a/ogcapi-types/src/common/crs.rs b/ogcapi-types/src/common/crs.rs index f24ba35..cb91384 100644 --- a/ogcapi-types/src/common/crs.rs +++ b/ogcapi-types/src/common/crs.rs @@ -17,7 +17,7 @@ pub struct Crs { } impl Crs { - pub fn new(authority: Authority, version: impl ToString, code: impl ToString) -> Crs { + pub fn new(authority: Authority, version: impl ToString, code: impl ToString) -> Self { Crs { authority, version: version.to_string(), @@ -25,13 +25,21 @@ impl Crs { } } + pub fn default2d() -> Self { + Self::new(Authority::OGC, "1.3".to_string(), "CRS84".to_string()) + } + + pub fn default3d() -> Self { + Self::new(Authority::OGC, "0".to_string(), "CRS84h".to_string()) + } + pub fn from_epsg(code: i32) -> Self { Crs::new(Authority::EPSG, "0", code) } pub fn from_srid(code: i32) -> Self { if code == 4326 { - Crs::default() + Crs::default2d() } else { Crs::new(Authority::EPSG, "0", code) } @@ -99,32 +107,25 @@ impl str::FromStr for Crs { type Err = String; fn from_str(s: &str) -> Result { - let parts: Vec<&str> = if s.starts_with("urn") { + let parts: Vec<&str> = if s.starts_with("http") { + s.trim_start_matches("http://www.opengis.net/def/crs/") + .split('/') + .collect() + } else if s.starts_with("urn") { s.trim_start_matches("urn:ogc:def:crs:") .split(':') .collect() } else { - s.trim_start_matches("http://www.opengis.net/def/crs/") - .split('/') - .collect() + s.split(':').collect() }; match parts.len() { 3 => Ok(Crs::new(Authority::from_str(parts[0])?, parts[1], parts[2])), + 2 => Ok(Crs::new(Authority::from_str(parts[0])?, "0", parts[1])), _ => Err(format!("Unable to parse CRS from `{s}`!")), } } } -impl Default for Crs { - fn default() -> Crs { - Crs { - authority: Authority::OGC, - version: "1.3".to_string(), - code: "CRS84".to_string(), - } - } -} - /// CRS Authorities #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Hash)] pub enum Authority { diff --git a/ogcapi-types/src/common/extent.rs b/ogcapi-types/src/common/extent.rs index cc46f80..ab011c4 100644 --- a/ogcapi-types/src/common/extent.rs +++ b/ogcapi-types/src/common/extent.rs @@ -30,9 +30,9 @@ pub struct SpatialExtent { /// Extensions may support additional coordinate reference systems and add /// additional enum values. #[serde(default)] - #[serde_as(as = "DisplayFromStr")] + #[serde_as(as = "Option")] #[schema(value_type = String)] - pub crs: Crs, + pub crs: Option, } impl Default for SpatialExtent { diff --git a/ogcapi-types/src/common/links.rs b/ogcapi-types/src/common/links.rs index b3def08..521c8a5 100644 --- a/ogcapi-types/src/common/links.rs +++ b/ogcapi-types/src/common/links.rs @@ -2,9 +2,6 @@ use url::Url; use super::{Link, link_rel::SELF}; -#[doc(hidden)] -pub type Links = Vec; - #[doc(hidden)] pub trait Linked { fn get_base_url(&mut self) -> Option; @@ -14,7 +11,7 @@ pub trait Linked { fn insert_or_update(&mut self, other: &[Link]); } -impl Linked for Links { +impl Linked for Vec { fn get_base_url(&mut self) -> Option { self.iter() .find(|l| l.rel == SELF) diff --git a/ogcapi-types/src/common/query.rs b/ogcapi-types/src/common/query.rs index 5184597..7845a6d 100644 --- a/ogcapi-types/src/common/query.rs +++ b/ogcapi-types/src/common/query.rs @@ -8,14 +8,14 @@ use crate::common::{Bbox, Crs, Datetime}; #[derive(Deserialize, ToSchema, Debug, Clone)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Query { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] pub bbox: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] #[schema(value_type = String)] pub bbox_crs: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] #[schema(value_type = String)] pub datetime: Option, @@ -28,7 +28,9 @@ pub struct Query { #[derive(Serialize, Deserialize, ToSchema, Debug, Clone, PartialEq, Eq)] pub struct LimitOffsetPagination { /// Amount of items to return + #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, /// Offset into the items list + #[serde(default, skip_serializing_if = "Option::is_none")] pub offset: Option, } diff --git a/ogcapi-types/src/edr/query.rs b/ogcapi-types/src/edr/query.rs index faba656..97c3f97 100644 --- a/ogcapi-types/src/edr/query.rs +++ b/ogcapi-types/src/edr/query.rs @@ -32,9 +32,9 @@ pub struct Query { pub datetime: Option, pub parameter_name: Option, #[serde(default)] - #[serde_as(as = "DisplayFromStr")] + #[serde_as(as = "Option")] #[param(value_type = String)] - pub crs: Crs, + pub crs: Option, pub f: Option, pub z: Option>, pub within: Option, diff --git a/ogcapi-types/src/features/feature.rs b/ogcapi-types/src/features/feature.rs index c8f42ae..cf367f3 100644 --- a/ogcapi-types/src/features/feature.rs +++ b/ogcapi-types/src/features/feature.rs @@ -31,7 +31,7 @@ pub enum Type { #[serde(untagged)] pub enum FeatureId { String(String), - Integer(isize), + Integer(u64), } impl Display for FeatureId { diff --git a/ogcapi-types/src/features/query.rs b/ogcapi-types/src/features/query.rs index 3ce3bb6..f9eeb5c 100644 --- a/ogcapi-types/src/features/query.rs +++ b/ogcapi-types/src/features/query.rs @@ -16,8 +16,10 @@ pub struct Query { /// Only items are counted that are on the first level of the collection /// in the response document. Nested objects contained /// within the explicitly requested items shall not be counted. + #[serde(default, skip_serializing_if = "Option::is_none")] #[param(nullable = false)] pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] #[param(nullable = false)] pub offset: Option, /// Only features that have a geometry that intersects the bounding box @@ -57,14 +59,14 @@ pub struct Query { /// If a feature has multiple spatial geometry properties, it is the /// decision of the server whether only a single spatial geometry property /// is used to determine the extent or all relevant geometries. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] #[param(value_type = Bbox, style = Form, explode = false, nullable = false)] pub bbox: Option, - #[serde(default)] - #[serde_as(as = "DisplayFromStr")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option")] #[param(value_type = String, nullable = false)] - pub bbox_crs: Crs, + pub bbox_crs: Option, /// Either a date-time or an interval. Date and time expressions adhere to /// RFC 3339. Intervals may be bounded or half-bounded (double-dots at start or end). /// @@ -80,20 +82,21 @@ pub struct Query { /// If a feature has multiple temporal properties, it is the decision of /// the server whether only a single temporal property is used to determine /// the extent or all relevant temporal properties. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] #[param(value_type = String)] pub datetime: Option, - #[serde(default)] - #[serde_as(as = "DisplayFromStr")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option")] #[param(value_type = String)] - pub crs: Crs, + pub crs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] #[param(nullable = false)] pub filter: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[param(inline, nullable = false)] pub filter_lang: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] #[serde_as(as = "Option")] #[param(value_type = String)] pub filter_crs: Option, diff --git a/ogcapi-types/src/movingfeatures/crs.rs b/ogcapi-types/src/movingfeatures/crs.rs index bec0e35..09b400b 100644 --- a/ogcapi-types/src/movingfeatures/crs.rs +++ b/ogcapi-types/src/movingfeatures/crs.rs @@ -33,7 +33,8 @@ pub enum Crs { impl Default for Crs { fn default() -> Self { Self::Name { - name: common::Crs::default().to_urn(), + // FIXME: Should this be respect 3d? + name: common::Crs::default2d().to_urn(), } } } @@ -43,7 +44,6 @@ impl TryFrom for common::Crs { fn try_from(value: Crs) -> Result { match value { - // TODO this might not work for names like "EPSG:4326" Crs::Name { name } => Self::from_str(name.as_str()), Crs::Link { href, .. } => Self::from_str(href.as_str()), } @@ -85,9 +85,9 @@ mod tests { #[test] fn into_common_crs() { // assert_eq!(common::Crs::try_from(Crs::default()).unwrap(), common::Crs::default()); - assert_eq!(common::Crs::default(), Crs::default().try_into().unwrap()); + assert_eq!(common::Crs::default2d(), Crs::default().try_into().unwrap()); // assert_eq!(Crs::from(common::Crs::default()), Crs::default()); - assert_eq!(Crs::default(), common::Crs::default().into()); + assert_eq!(Crs::default(), common::Crs::default2d().into()); } }