use crate::error::{ServiceError, ServiceResult};
use crate::wfs::database::{
BboxFilter, CountCacheConfig, CqlFilter, DatabaseFeatureCounter, DatabaseSource, DatabaseType,
};
use crate::wfs::{FeatureSource, WfsState};
use axum::{
http::header,
response::{IntoResponse, Response},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub struct GetFeatureParams {
#[serde(rename = "TYPENAME", alias = "TYPENAMES")]
pub type_names: String,
#[serde(default = "default_output_format")]
pub output_format: String,
#[serde(rename = "COUNT", alias = "MAXFEATURES")]
pub count: Option<usize>,
#[serde(default = "default_result_type")]
pub result_type: String,
pub bbox: Option<String>,
pub filter: Option<String>,
pub property_name: Option<String>,
pub sortby: Option<String>,
#[serde(rename = "STARTINDEX")]
pub start_index: Option<usize>,
pub srsname: Option<String>,
}
fn default_output_format() -> String {
"application/gml+xml; version=3.2".to_string()
}
fn default_result_type() -> String {
"results".to_string()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub struct DescribeFeatureTypeParams {
#[serde(rename = "TYPENAME", alias = "TYPENAMES")]
pub type_names: Option<String>,
#[serde(default = "default_schema_format")]
pub output_format: String,
}
fn default_schema_format() -> String {
"application/gml+xml; version=3.2".to_string()
}
pub async fn handle_get_feature(
state: &WfsState,
_version: &str,
params: &serde_json::Value,
) -> Result<Response, ServiceError> {
let params: GetFeatureParams = serde_json::from_value(params.clone())
.map_err(|e| ServiceError::InvalidParameter("Parameters".to_string(), e.to_string()))?;
let type_names: Vec<&str> = params.type_names.split(',').collect();
for type_name in &type_names {
if state.get_feature_type(type_name.trim()).is_none() {
return Err(ServiceError::NotFound(format!(
"Feature type not found: {}",
type_name
)));
}
}
if params.result_type.to_lowercase() == "hits" {
return generate_hits_response(state, &type_names, ¶ms);
}
match params.output_format.as_str() {
"application/json" | "application/geo+json" => {
generate_geojson_response(state, &type_names, ¶ms).await
}
_ => generate_gml_response(state, &type_names, ¶ms).await,
}
}
pub async fn handle_describe_feature_type(
state: &WfsState,
_version: &str,
params: &serde_json::Value,
) -> Result<Response, ServiceError> {
let params: DescribeFeatureTypeParams = serde_json::from_value(params.clone())
.map_err(|e| ServiceError::InvalidParameter("Parameters".to_string(), e.to_string()))?;
let type_names: Vec<String> = if let Some(ref names) = params.type_names {
names.split(',').map(|s| s.trim().to_string()).collect()
} else {
state
.feature_types
.iter()
.map(|entry| entry.key().clone())
.collect()
};
let type_names_refs: Vec<&str> = type_names.iter().map(|s| s.as_str()).collect();
generate_feature_schema(state, &type_names_refs)
}
static FEATURE_COUNTER: std::sync::OnceLock<DatabaseFeatureCounter> = std::sync::OnceLock::new();
fn get_feature_counter() -> &'static DatabaseFeatureCounter {
FEATURE_COUNTER.get_or_init(|| DatabaseFeatureCounter::new(CountCacheConfig::default()))
}
fn generate_hits_response(
state: &WfsState,
type_names: &[&str],
params: &GetFeatureParams,
) -> Result<Response, ServiceError> {
let rt = tokio::runtime::Handle::try_current().map_err(|_| {
ServiceError::Internal("No async runtime available for database operations".to_string())
})?;
let mut total_count = 0usize;
let mut any_estimated = false;
for type_name in type_names {
let ft = state
.get_feature_type(type_name.trim())
.ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
let (count, is_estimated) = match &ft.source {
FeatureSource::Memory(features) => {
let filtered = apply_memory_filters(features, params)?;
(filtered.len(), false)
}
FeatureSource::File(path) => {
let count = count_features_in_file_filtered(path, params)?;
(count, false)
}
FeatureSource::Database(conn_string) => {
let db_source = create_legacy_database_source(conn_string, type_name);
rt.block_on(count_database_features(&db_source, params))?
}
FeatureSource::DatabaseSource(db_source) => {
rt.block_on(count_database_features(db_source, params))?
}
};
total_count += count;
if is_estimated {
any_estimated = true;
}
}
if let Some(max_count) = params.count {
total_count = total_count.min(max_count);
}
let number_matched = if any_estimated {
format!("~{}", total_count)
} else {
total_count.to_string()
};
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<wfs:FeatureCollection
xmlns:wfs="http://www.opengis.net/wfs/2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
numberMatched="{}"
numberReturned="0"
timeStamp="{}">
</wfs:FeatureCollection>"#,
number_matched,
chrono::Utc::now().to_rfc3339()
);
Ok(([(header::CONTENT_TYPE, "application/xml")], xml).into_response())
}
fn create_legacy_database_source(conn_string: &str, table_name: &str) -> DatabaseSource {
let db_type =
if conn_string.starts_with("postgresql://") || conn_string.starts_with("postgres://") {
DatabaseType::PostGis
} else if conn_string.starts_with("mysql://") {
DatabaseType::MySql
} else if conn_string.ends_with(".db") || conn_string.ends_with(".sqlite") {
DatabaseType::Sqlite
} else {
DatabaseType::Generic
};
DatabaseSource::new(conn_string, table_name).with_database_type(db_type)
}
async fn count_database_features(
source: &DatabaseSource,
params: &GetFeatureParams,
) -> ServiceResult<(usize, bool)> {
let counter = get_feature_counter();
let bbox_filter = if let Some(ref bbox_str) = params.bbox {
Some(BboxFilter::from_bbox_string(bbox_str)?)
} else {
None
};
let cql_filter = params.filter.as_ref().map(CqlFilter::new);
let result = counter
.get_count(source, cql_filter.as_ref(), bbox_filter.as_ref())
.await?;
Ok((result.count, result.is_estimated))
}
fn apply_memory_filters<'a>(
features: &'a [geojson::Feature],
params: &'a GetFeatureParams,
) -> ServiceResult<Vec<&'a geojson::Feature>> {
let mut filtered: Vec<&geojson::Feature> = features.iter().collect();
if let Some(ref bbox_str) = params.bbox {
let bbox = BboxFilter::from_bbox_string(bbox_str)?;
filtered.retain(|f| feature_in_bbox(f, &bbox));
}
if let Some(ref filter_str) = params.filter {
filtered.retain(|f| feature_matches_filter(f, filter_str));
}
Ok(filtered)
}
fn feature_in_bbox(feature: &geojson::Feature, bbox: &BboxFilter) -> bool {
if let Some(ref geom_bbox) = feature.bbox {
if geom_bbox.len() >= 4 {
return geom_bbox[0] <= bbox.max_x
&& geom_bbox[2] >= bbox.min_x
&& geom_bbox[1] <= bbox.max_y
&& geom_bbox[3] >= bbox.min_y;
}
}
if let Some(ref geometry) = feature.geometry {
return geometry_intersects_bbox(geometry, bbox);
}
false
}
fn geometry_intersects_bbox(geometry: &geojson::Geometry, bbox: &BboxFilter) -> bool {
use geojson::GeometryValue;
match &geometry.value {
GeometryValue::Point {
coordinates: coords,
} => {
coords.len() >= 2
&& coords[0] >= bbox.min_x
&& coords[0] <= bbox.max_x
&& coords[1] >= bbox.min_y
&& coords[1] <= bbox.max_y
}
GeometryValue::MultiPoint {
coordinates: points,
} => points.iter().any(|coords| {
coords.len() >= 2
&& coords[0] >= bbox.min_x
&& coords[0] <= bbox.max_x
&& coords[1] >= bbox.min_y
&& coords[1] <= bbox.max_y
}),
GeometryValue::LineString {
coordinates: coords,
} => coords.iter().any(|c| {
c.len() >= 2
&& c[0] >= bbox.min_x
&& c[0] <= bbox.max_x
&& c[1] >= bbox.min_y
&& c[1] <= bbox.max_y
}),
GeometryValue::MultiLineString { coordinates: lines } => lines.iter().any(|line| {
line.iter().any(|c| {
c.len() >= 2
&& c[0] >= bbox.min_x
&& c[0] <= bbox.max_x
&& c[1] >= bbox.min_y
&& c[1] <= bbox.max_y
})
}),
GeometryValue::Polygon { coordinates: rings } => rings.iter().any(|ring| {
ring.iter().any(|c| {
c.len() >= 2
&& c[0] >= bbox.min_x
&& c[0] <= bbox.max_x
&& c[1] >= bbox.min_y
&& c[1] <= bbox.max_y
})
}),
GeometryValue::MultiPolygon {
coordinates: polygons,
} => polygons.iter().any(|polygon| {
polygon.iter().any(|ring| {
ring.iter().any(|c| {
c.len() >= 2
&& c[0] >= bbox.min_x
&& c[0] <= bbox.max_x
&& c[1] >= bbox.min_y
&& c[1] <= bbox.max_y
})
})
}),
GeometryValue::GeometryCollection { geometries: geoms } => {
geoms.iter().any(|g| geometry_intersects_bbox(g, bbox))
}
}
}
fn feature_matches_filter(feature: &geojson::Feature, filter_str: &str) -> bool {
let props = match &feature.properties {
Some(p) => p,
None => return false,
};
let filter = filter_str.trim();
if filter.contains('=')
&& !filter.contains("!=")
&& !filter.contains("<=")
&& !filter.contains(">=")
{
let parts: Vec<&str> = filter.splitn(2, '=').collect();
if parts.len() == 2 {
let prop_name = parts[0].trim().trim_matches('"').trim_matches('\'');
let prop_value = parts[1].trim().trim_matches('\'').trim_matches('"');
if let Some(value) = props.get(prop_name) {
return match value {
serde_json::Value::String(s) => s == prop_value,
serde_json::Value::Number(n) => n.to_string() == prop_value,
serde_json::Value::Bool(b) => b.to_string() == prop_value,
_ => false,
};
}
}
}
true
}
fn count_features_in_file_filtered(
path: &std::path::Path,
params: &GetFeatureParams,
) -> ServiceResult<usize> {
let features = load_features_from_file(path)?;
let filtered = apply_memory_filters(&features, params)?;
Ok(filtered.len())
}
async fn generate_geojson_response(
state: &WfsState,
type_names: &[&str],
params: &GetFeatureParams,
) -> Result<Response, ServiceError> {
let mut all_features = Vec::new();
for type_name in type_names {
let ft = state
.get_feature_type(type_name.trim())
.ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
let mut features = match &ft.source {
FeatureSource::Memory(features) => features.clone(),
FeatureSource::File(path) => load_features_from_file(path)?,
FeatureSource::Database(_) => {
return Err(ServiceError::Internal(
"Database sources not yet implemented".to_string(),
));
}
FeatureSource::DatabaseSource(db_source) => {
return Err(ServiceError::Internal(format!(
"DatabaseSource feature retrieval not yet implemented for table '{}'. \
Use oxigdal-postgis for PostGIS connections.",
db_source.table_name
)));
}
};
if let Some(ref bbox_str) = params.bbox {
features = apply_bbox_filter(features, bbox_str)?;
}
if let Some(ref props) = params.property_name {
features = filter_properties(features, props)?;
}
if let Some(start) = params.start_index {
features = features.into_iter().skip(start).collect();
}
if let Some(max_count) = params.count {
features.truncate(max_count);
}
all_features.extend(features);
}
let feature_collection = geojson::FeatureCollection {
bbox: None,
features: all_features,
foreign_members: None,
};
let json = serde_json::to_string_pretty(&feature_collection)
.map_err(|e| ServiceError::Serialization(e.to_string()))?;
Ok(([(header::CONTENT_TYPE, "application/geo+json")], json).into_response())
}
async fn generate_gml_response(
state: &WfsState,
type_names: &[&str],
params: &GetFeatureParams,
) -> Result<Response, ServiceError> {
let _geojson_response = generate_geojson_response(state, type_names, params).await?;
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<wfs:FeatureCollection
xmlns:wfs="http://www.opengis.net/wfs/2.0"
xmlns:gml="http://www.opengis.net/gml/3.2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
timeStamp="{}">
<!-- GML encoding would go here -->
<!-- For production, use full GML 3.2 encoding -->
</wfs:FeatureCollection>"#,
chrono::Utc::now().to_rfc3339()
);
Ok(([(header::CONTENT_TYPE, "application/gml+xml")], xml).into_response())
}
fn generate_feature_schema(
state: &WfsState,
type_names: &[&str],
) -> Result<Response, ServiceError> {
use quick_xml::{
Writer,
events::{BytesDecl, BytesEnd, BytesStart, Event},
};
use std::io::Cursor;
let mut writer = Writer::new(Cursor::new(Vec::new()));
writer
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
.map_err(|e| ServiceError::Xml(e.to_string()))?;
let mut schema = BytesStart::new("xsd:schema");
schema.push_attribute(("xmlns:xsd", "http://www.w3.org/2001/XMLSchema"));
schema.push_attribute(("xmlns:gml", "http://www.opengis.net/gml/3.2"));
schema.push_attribute(("elementFormDefault", "qualified"));
schema.push_attribute(("targetNamespace", "http://www.opengis.net/wfs/2.0"));
writer
.write_event(Event::Start(schema))
.map_err(|e| ServiceError::Xml(e.to_string()))?;
let mut import = BytesStart::new("xsd:import");
import.push_attribute(("namespace", "http://www.opengis.net/gml/3.2"));
import.push_attribute((
"schemaLocation",
"http://schemas.opengis.net/gml/3.2.1/gml.xsd",
));
writer
.write_event(Event::Empty(import))
.map_err(|e| ServiceError::Xml(e.to_string()))?;
for type_name in type_names {
let _ft = state
.get_feature_type(type_name)
.ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
let mut element = BytesStart::new("xsd:element");
element.push_attribute(("name", *type_name));
element.push_attribute(("type", "gml:AbstractFeatureType"));
element.push_attribute(("substitutionGroup", "gml:AbstractFeature"));
writer
.write_event(Event::Empty(element))
.map_err(|e| ServiceError::Xml(e.to_string()))?;
}
writer
.write_event(Event::End(BytesEnd::new("xsd:schema")))
.map_err(|e| ServiceError::Xml(e.to_string()))?;
let xml = String::from_utf8(writer.into_inner().into_inner())
.map_err(|e| ServiceError::Xml(e.to_string()))?;
Ok(([(header::CONTENT_TYPE, "application/xml")], xml).into_response())
}
fn load_features_from_file(path: &std::path::Path) -> ServiceResult<Vec<geojson::Feature>> {
let contents = std::fs::read_to_string(path)?;
match path.extension().and_then(|e| e.to_str()) {
Some("geojson") | Some("json") => {
let geojson: geojson::GeoJson = contents.parse()?;
match geojson {
geojson::GeoJson::FeatureCollection(fc) => Ok(fc.features),
geojson::GeoJson::Feature(f) => Ok(vec![f]),
_ => Err(ServiceError::InvalidGeoJson(
"Expected FeatureCollection or Feature".to_string(),
)),
}
}
_ => Err(ServiceError::UnsupportedFormat(format!(
"Unsupported file format: {:?}",
path.extension()
))),
}
}
fn apply_bbox_filter(
features: Vec<geojson::Feature>,
bbox_str: &str,
) -> ServiceResult<Vec<geojson::Feature>> {
let parts: Vec<&str> = bbox_str.split(',').collect();
if parts.len() < 4 {
return Err(ServiceError::InvalidBbox(
"BBOX must have at least 4 coordinates".to_string(),
));
}
let minx: f64 = parts[0]
.parse()
.map_err(|_| ServiceError::InvalidBbox("Invalid minx".to_string()))?;
let miny: f64 = parts[1]
.parse()
.map_err(|_| ServiceError::InvalidBbox("Invalid miny".to_string()))?;
let maxx: f64 = parts[2]
.parse()
.map_err(|_| ServiceError::InvalidBbox("Invalid maxx".to_string()))?;
let maxy: f64 = parts[3]
.parse()
.map_err(|_| ServiceError::InvalidBbox("Invalid maxy".to_string()))?;
let filtered: Vec<_> = features
.into_iter()
.filter(|f| {
if let Some(ref _geometry) = f.geometry {
if let Some(bbox) = &f.bbox {
bbox.len() >= 4
&& bbox[0] <= maxx
&& bbox[2] >= minx
&& bbox[1] <= maxy
&& bbox[3] >= miny
} else {
true
}
} else {
false
}
})
.collect();
Ok(filtered)
}
fn filter_properties(
features: Vec<geojson::Feature>,
property_names: &str,
) -> ServiceResult<Vec<geojson::Feature>> {
let names: Vec<&str> = property_names.split(',').map(|s| s.trim()).collect();
let filtered: Vec<_> = features
.into_iter()
.map(|mut f| {
if let Some(ref mut props) = f.properties {
let filtered_props: serde_json::Map<String, serde_json::Value> = props
.iter()
.filter(|(k, _)| names.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
f.properties = Some(filtered_props);
}
f
})
.collect();
Ok(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wfs::{FeatureTypeInfo, ServiceInfo};
#[tokio::test]
async fn test_describe_feature_type() -> Result<(), Box<dyn std::error::Error>> {
let info = ServiceInfo {
title: "Test WFS".to_string(),
abstract_text: None,
provider: "COOLJAPAN OU".to_string(),
service_url: "http://localhost/wfs".to_string(),
versions: vec!["2.0.0".to_string()],
};
let state = WfsState::new(info);
let ft = FeatureTypeInfo {
name: "test_layer".to_string(),
title: "Test Layer".to_string(),
abstract_text: None,
default_crs: "EPSG:4326".to_string(),
other_crs: vec![],
bbox: None,
source: FeatureSource::Memory(vec![]),
};
state.add_feature_type(ft)?;
let params = serde_json::json!({
"TYPENAMES": "test_layer",
"OUTPUTFORMAT": "application/xml"
});
let response = handle_describe_feature_type(&state, "2.0.0", ¶ms).await?;
let (parts, _) = response.into_parts();
assert_eq!(
parts
.headers
.get(header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok()),
Some("application/xml")
);
Ok(())
}
#[test]
fn test_bbox_parsing() {
let features = vec![];
let result = apply_bbox_filter(features, "-180,-90,180,90");
assert!(result.is_ok());
let result = apply_bbox_filter(vec![], "invalid");
assert!(result.is_err());
}
}