Skip to main content

oxigdal_services/wfs/
features.rs

1//! WFS feature retrieval and description
2//!
3//! Implements GetFeature and DescribeFeatureType operations for
4//! querying and retrieving geospatial features.
5
6use crate::error::{ServiceError, ServiceResult};
7use crate::wfs::database::{
8    BboxFilter, CountCacheConfig, CqlFilter, DatabaseFeatureCounter, DatabaseSource, DatabaseType,
9};
10use crate::wfs::{FeatureSource, WfsState};
11use axum::{
12    http::header,
13    response::{IntoResponse, Response},
14};
15use serde::Deserialize;
16
17/// GetFeature parameters
18#[derive(Debug, Deserialize)]
19#[serde(rename_all = "UPPERCASE")]
20pub struct GetFeatureParams {
21    /// Feature type names (comma-separated)
22    #[serde(rename = "TYPENAME", alias = "TYPENAMES")]
23    pub type_names: String,
24    /// Output format
25    #[serde(default = "default_output_format")]
26    pub output_format: String,
27    /// Maximum number of features
28    #[serde(rename = "COUNT", alias = "MAXFEATURES")]
29    pub count: Option<usize>,
30    /// Result type (results or hits)
31    #[serde(default = "default_result_type")]
32    pub result_type: String,
33    /// BBOX filter (minx,miny,maxx,maxy\[,crs\])
34    pub bbox: Option<String>,
35    /// Filter (FE filter encoding)
36    pub filter: Option<String>,
37    /// Property names to return
38    pub property_name: Option<String>,
39    /// Sort by properties
40    pub sortby: Option<String>,
41    /// Start index for pagination
42    #[serde(rename = "STARTINDEX")]
43    pub start_index: Option<usize>,
44    /// CRS for output
45    pub srsname: Option<String>,
46}
47
48fn default_output_format() -> String {
49    "application/gml+xml; version=3.2".to_string()
50}
51
52fn default_result_type() -> String {
53    "results".to_string()
54}
55
56/// DescribeFeatureType parameters
57#[derive(Debug, Deserialize)]
58#[serde(rename_all = "UPPERCASE")]
59pub struct DescribeFeatureTypeParams {
60    /// Feature type names (comma-separated)
61    #[serde(rename = "TYPENAME", alias = "TYPENAMES")]
62    pub type_names: Option<String>,
63    /// Output format
64    #[serde(default = "default_schema_format")]
65    pub output_format: String,
66}
67
68fn default_schema_format() -> String {
69    "application/gml+xml; version=3.2".to_string()
70}
71
72/// Handle GetFeature request
73pub async fn handle_get_feature(
74    state: &WfsState,
75    _version: &str,
76    params: &serde_json::Value,
77) -> Result<Response, ServiceError> {
78    let params: GetFeatureParams = serde_json::from_value(params.clone())
79        .map_err(|e| ServiceError::InvalidParameter("Parameters".to_string(), e.to_string()))?;
80
81    // Parse type names
82    let type_names: Vec<&str> = params.type_names.split(',').collect();
83
84    // Validate all type names exist
85    for type_name in &type_names {
86        if state.get_feature_type(type_name.trim()).is_none() {
87            return Err(ServiceError::NotFound(format!(
88                "Feature type not found: {}",
89                type_name
90            )));
91        }
92    }
93
94    // Handle result_type=hits (just count, no features)
95    if params.result_type.to_lowercase() == "hits" {
96        return generate_hits_response(state, &type_names, &params);
97    }
98
99    // Determine output format
100    match params.output_format.as_str() {
101        "application/json" | "application/geo+json" => {
102            generate_geojson_response(state, &type_names, &params).await
103        }
104        _ => generate_gml_response(state, &type_names, &params).await,
105    }
106}
107
108/// Handle DescribeFeatureType request
109pub async fn handle_describe_feature_type(
110    state: &WfsState,
111    _version: &str,
112    params: &serde_json::Value,
113) -> Result<Response, ServiceError> {
114    let params: DescribeFeatureTypeParams = serde_json::from_value(params.clone())
115        .map_err(|e| ServiceError::InvalidParameter("Parameters".to_string(), e.to_string()))?;
116
117    let type_names: Vec<String> = if let Some(ref names) = params.type_names {
118        names.split(',').map(|s| s.trim().to_string()).collect()
119    } else {
120        // Return all feature types
121        state
122            .feature_types
123            .iter()
124            .map(|entry| entry.key().clone())
125            .collect()
126    };
127    let type_names_refs: Vec<&str> = type_names.iter().map(|s| s.as_str()).collect();
128
129    generate_feature_schema(state, &type_names_refs)
130}
131
132/// Global feature counter with caching
133static FEATURE_COUNTER: std::sync::OnceLock<DatabaseFeatureCounter> = std::sync::OnceLock::new();
134
135/// Get or initialize the feature counter
136fn get_feature_counter() -> &'static DatabaseFeatureCounter {
137    FEATURE_COUNTER.get_or_init(|| DatabaseFeatureCounter::new(CountCacheConfig::default()))
138}
139
140/// Generate hits response (feature count only)
141fn generate_hits_response(
142    state: &WfsState,
143    type_names: &[&str],
144    params: &GetFeatureParams,
145) -> Result<Response, ServiceError> {
146    // Use tokio runtime for async database operations
147    let rt = tokio::runtime::Handle::try_current().map_err(|_| {
148        ServiceError::Internal("No async runtime available for database operations".to_string())
149    })?;
150
151    let mut total_count = 0usize;
152    let mut any_estimated = false;
153
154    for type_name in type_names {
155        let ft = state
156            .get_feature_type(type_name.trim())
157            .ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
158
159        let (count, is_estimated) = match &ft.source {
160            FeatureSource::Memory(features) => {
161                // For memory sources, apply filters directly
162                let filtered = apply_memory_filters(features, params)?;
163                (filtered.len(), false)
164            }
165            FeatureSource::File(path) => {
166                // Count features in file with optional filtering
167                let count = count_features_in_file_filtered(path, params)?;
168                (count, false)
169            }
170            FeatureSource::Database(conn_string) => {
171                // Legacy database source - create a temporary DatabaseSource
172                let db_source = create_legacy_database_source(conn_string, type_name);
173                rt.block_on(count_database_features(&db_source, params))?
174            }
175            FeatureSource::DatabaseSource(db_source) => {
176                // Full database source with proper configuration
177                rt.block_on(count_database_features(db_source, params))?
178            }
179        };
180
181        total_count += count;
182        if is_estimated {
183            any_estimated = true;
184        }
185    }
186
187    // Apply count limit
188    if let Some(max_count) = params.count {
189        total_count = total_count.min(max_count);
190    }
191
192    // Build response with optional estimation indicator
193    let number_matched = if any_estimated {
194        format!("~{}", total_count)
195    } else {
196        total_count.to_string()
197    };
198
199    let xml = format!(
200        r#"<?xml version="1.0" encoding="UTF-8"?>
201<wfs:FeatureCollection
202    xmlns:wfs="http://www.opengis.net/wfs/2.0"
203    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
204    numberMatched="{}"
205    numberReturned="0"
206    timeStamp="{}">
207</wfs:FeatureCollection>"#,
208        number_matched,
209        chrono::Utc::now().to_rfc3339()
210    );
211
212    Ok(([(header::CONTENT_TYPE, "application/xml")], xml).into_response())
213}
214
215/// Create a legacy database source from a connection string
216fn create_legacy_database_source(conn_string: &str, table_name: &str) -> DatabaseSource {
217    // Infer database type from connection string
218    let db_type =
219        if conn_string.starts_with("postgresql://") || conn_string.starts_with("postgres://") {
220            DatabaseType::PostGis
221        } else if conn_string.starts_with("mysql://") {
222            DatabaseType::MySql
223        } else if conn_string.ends_with(".db") || conn_string.ends_with(".sqlite") {
224            DatabaseType::Sqlite
225        } else {
226            DatabaseType::Generic
227        };
228
229    DatabaseSource::new(conn_string, table_name).with_database_type(db_type)
230}
231
232/// Count features in database with proper caching and filtering
233async fn count_database_features(
234    source: &DatabaseSource,
235    params: &GetFeatureParams,
236) -> ServiceResult<(usize, bool)> {
237    let counter = get_feature_counter();
238
239    // Parse BBOX filter if present
240    let bbox_filter = if let Some(ref bbox_str) = params.bbox {
241        Some(BboxFilter::from_bbox_string(bbox_str)?)
242    } else {
243        None
244    };
245
246    // Parse CQL filter if present
247    let cql_filter = params.filter.as_ref().map(CqlFilter::new);
248
249    // Get count from database (with caching)
250    let result = counter
251        .get_count(source, cql_filter.as_ref(), bbox_filter.as_ref())
252        .await?;
253
254    Ok((result.count, result.is_estimated))
255}
256
257/// Apply filters to memory-based features and count
258fn apply_memory_filters<'a>(
259    features: &'a [geojson::Feature],
260    params: &'a GetFeatureParams,
261) -> ServiceResult<Vec<&'a geojson::Feature>> {
262    let mut filtered: Vec<&geojson::Feature> = features.iter().collect();
263
264    // Apply BBOX filter
265    if let Some(ref bbox_str) = params.bbox {
266        let bbox = BboxFilter::from_bbox_string(bbox_str)?;
267        filtered.retain(|f| feature_in_bbox(f, &bbox));
268    }
269
270    // Apply CQL filter (simplified - property filtering)
271    if let Some(ref filter_str) = params.filter {
272        filtered.retain(|f| feature_matches_filter(f, filter_str));
273    }
274
275    Ok(filtered)
276}
277
278/// Check if a feature is within a bounding box
279fn feature_in_bbox(feature: &geojson::Feature, bbox: &BboxFilter) -> bool {
280    if let Some(ref geom_bbox) = feature.bbox {
281        if geom_bbox.len() >= 4 {
282            return geom_bbox[0] <= bbox.max_x
283                && geom_bbox[2] >= bbox.min_x
284                && geom_bbox[1] <= bbox.max_y
285                && geom_bbox[3] >= bbox.min_y;
286        }
287    }
288
289    // If no bbox on feature, check geometry bounds
290    if let Some(ref geometry) = feature.geometry {
291        return geometry_intersects_bbox(geometry, bbox);
292    }
293
294    false
295}
296
297/// Check if geometry intersects bbox
298fn geometry_intersects_bbox(geometry: &geojson::Geometry, bbox: &BboxFilter) -> bool {
299    use geojson::GeometryValue;
300
301    match &geometry.value {
302        GeometryValue::Point {
303            coordinates: coords,
304        } => {
305            coords.len() >= 2
306                && coords[0] >= bbox.min_x
307                && coords[0] <= bbox.max_x
308                && coords[1] >= bbox.min_y
309                && coords[1] <= bbox.max_y
310        }
311        GeometryValue::MultiPoint {
312            coordinates: points,
313        } => points.iter().any(|coords| {
314            coords.len() >= 2
315                && coords[0] >= bbox.min_x
316                && coords[0] <= bbox.max_x
317                && coords[1] >= bbox.min_y
318                && coords[1] <= bbox.max_y
319        }),
320        GeometryValue::LineString {
321            coordinates: coords,
322        } => coords.iter().any(|c| {
323            c.len() >= 2
324                && c[0] >= bbox.min_x
325                && c[0] <= bbox.max_x
326                && c[1] >= bbox.min_y
327                && c[1] <= bbox.max_y
328        }),
329        GeometryValue::MultiLineString { coordinates: lines } => lines.iter().any(|line| {
330            line.iter().any(|c| {
331                c.len() >= 2
332                    && c[0] >= bbox.min_x
333                    && c[0] <= bbox.max_x
334                    && c[1] >= bbox.min_y
335                    && c[1] <= bbox.max_y
336            })
337        }),
338        GeometryValue::Polygon { coordinates: rings } => rings.iter().any(|ring| {
339            ring.iter().any(|c| {
340                c.len() >= 2
341                    && c[0] >= bbox.min_x
342                    && c[0] <= bbox.max_x
343                    && c[1] >= bbox.min_y
344                    && c[1] <= bbox.max_y
345            })
346        }),
347        GeometryValue::MultiPolygon {
348            coordinates: polygons,
349        } => polygons.iter().any(|polygon| {
350            polygon.iter().any(|ring| {
351                ring.iter().any(|c| {
352                    c.len() >= 2
353                        && c[0] >= bbox.min_x
354                        && c[0] <= bbox.max_x
355                        && c[1] >= bbox.min_y
356                        && c[1] <= bbox.max_y
357                })
358            })
359        }),
360        GeometryValue::GeometryCollection { geometries: geoms } => {
361            geoms.iter().any(|g| geometry_intersects_bbox(g, bbox))
362        }
363    }
364}
365
366/// Check if feature matches a filter expression (simplified)
367fn feature_matches_filter(feature: &geojson::Feature, filter_str: &str) -> bool {
368    // Simplified filter matching - parse basic property comparisons
369    // Full CQL parsing is handled by the CqlFilter type for databases
370
371    let props = match &feature.properties {
372        Some(p) => p,
373        None => return false,
374    };
375
376    // Try to parse simple "property = 'value'" expressions
377    let filter = filter_str.trim();
378
379    // Handle basic equality check
380    if filter.contains('=')
381        && !filter.contains("!=")
382        && !filter.contains("<=")
383        && !filter.contains(">=")
384    {
385        let parts: Vec<&str> = filter.splitn(2, '=').collect();
386        if parts.len() == 2 {
387            let prop_name = parts[0].trim().trim_matches('"').trim_matches('\'');
388            let prop_value = parts[1].trim().trim_matches('\'').trim_matches('"');
389
390            if let Some(value) = props.get(prop_name) {
391                return match value {
392                    serde_json::Value::String(s) => s == prop_value,
393                    serde_json::Value::Number(n) => n.to_string() == prop_value,
394                    serde_json::Value::Bool(b) => b.to_string() == prop_value,
395                    _ => false,
396                };
397            }
398        }
399    }
400
401    // Default to true if we can't parse the filter
402    // (actual filtering happens at database level)
403    true
404}
405
406/// Count features in file with filtering
407fn count_features_in_file_filtered(
408    path: &std::path::Path,
409    params: &GetFeatureParams,
410) -> ServiceResult<usize> {
411    let features = load_features_from_file(path)?;
412
413    // Apply filters
414    let filtered = apply_memory_filters(&features, params)?;
415
416    Ok(filtered.len())
417}
418
419/// Generate GeoJSON response
420async fn generate_geojson_response(
421    state: &WfsState,
422    type_names: &[&str],
423    params: &GetFeatureParams,
424) -> Result<Response, ServiceError> {
425    let mut all_features = Vec::new();
426
427    for type_name in type_names {
428        let ft = state
429            .get_feature_type(type_name.trim())
430            .ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
431
432        let mut features = match &ft.source {
433            FeatureSource::Memory(features) => features.clone(),
434            FeatureSource::File(path) => load_features_from_file(path)?,
435            FeatureSource::Database(_) => {
436                return Err(ServiceError::Internal(
437                    "Database sources not yet implemented".to_string(),
438                ));
439            }
440            FeatureSource::DatabaseSource(db_source) => {
441                // DatabaseSource provides full configuration for database-backed features
442                // Currently, feature retrieval requires oxigdal-postgis or oxigdal-db-connectors integration
443                return Err(ServiceError::Internal(format!(
444                    "DatabaseSource feature retrieval not yet implemented for table '{}'. \
445                     Use oxigdal-postgis for PostGIS connections.",
446                    db_source.table_name
447                )));
448            }
449        };
450
451        // Apply BBOX filter
452        if let Some(ref bbox_str) = params.bbox {
453            features = apply_bbox_filter(features, bbox_str)?;
454        }
455
456        // Apply property filter
457        if let Some(ref props) = params.property_name {
458            features = filter_properties(features, props)?;
459        }
460
461        // Apply start index
462        if let Some(start) = params.start_index {
463            features = features.into_iter().skip(start).collect();
464        }
465
466        // Apply count limit
467        if let Some(max_count) = params.count {
468            features.truncate(max_count);
469        }
470
471        all_features.extend(features);
472    }
473
474    let feature_collection = geojson::FeatureCollection {
475        bbox: None,
476        features: all_features,
477        foreign_members: None,
478    };
479
480    let json = serde_json::to_string_pretty(&feature_collection)
481        .map_err(|e| ServiceError::Serialization(e.to_string()))?;
482
483    Ok(([(header::CONTENT_TYPE, "application/geo+json")], json).into_response())
484}
485
486/// Generate GML response
487async fn generate_gml_response(
488    state: &WfsState,
489    type_names: &[&str],
490    params: &GetFeatureParams,
491) -> Result<Response, ServiceError> {
492    // For now, convert to GeoJSON first, then wrap in GML
493    // A full GML implementation would be more complex
494    let _geojson_response = generate_geojson_response(state, type_names, params).await?;
495
496    // Simple GML wrapper
497    let xml = format!(
498        r#"<?xml version="1.0" encoding="UTF-8"?>
499<wfs:FeatureCollection
500    xmlns:wfs="http://www.opengis.net/wfs/2.0"
501    xmlns:gml="http://www.opengis.net/gml/3.2"
502    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
503    timeStamp="{}">
504  <!-- GML encoding would go here -->
505  <!-- For production, use full GML 3.2 encoding -->
506</wfs:FeatureCollection>"#,
507        chrono::Utc::now().to_rfc3339()
508    );
509
510    Ok(([(header::CONTENT_TYPE, "application/gml+xml")], xml).into_response())
511}
512
513/// Generate XML schema for feature types
514fn generate_feature_schema(
515    state: &WfsState,
516    type_names: &[&str],
517) -> Result<Response, ServiceError> {
518    use quick_xml::{
519        Writer,
520        events::{BytesDecl, BytesEnd, BytesStart, Event},
521    };
522    use std::io::Cursor;
523
524    let mut writer = Writer::new(Cursor::new(Vec::new()));
525
526    writer
527        .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
528        .map_err(|e| ServiceError::Xml(e.to_string()))?;
529
530    let mut schema = BytesStart::new("xsd:schema");
531    schema.push_attribute(("xmlns:xsd", "http://www.w3.org/2001/XMLSchema"));
532    schema.push_attribute(("xmlns:gml", "http://www.opengis.net/gml/3.2"));
533    schema.push_attribute(("elementFormDefault", "qualified"));
534    schema.push_attribute(("targetNamespace", "http://www.opengis.net/wfs/2.0"));
535
536    writer
537        .write_event(Event::Start(schema))
538        .map_err(|e| ServiceError::Xml(e.to_string()))?;
539
540    // Import GML schema
541    let mut import = BytesStart::new("xsd:import");
542    import.push_attribute(("namespace", "http://www.opengis.net/gml/3.2"));
543    import.push_attribute((
544        "schemaLocation",
545        "http://schemas.opengis.net/gml/3.2.1/gml.xsd",
546    ));
547    writer
548        .write_event(Event::Empty(import))
549        .map_err(|e| ServiceError::Xml(e.to_string()))?;
550
551    for type_name in type_names {
552        let _ft = state
553            .get_feature_type(type_name)
554            .ok_or_else(|| ServiceError::NotFound(format!("Feature type: {}", type_name)))?;
555
556        // Simple schema definition
557        let mut element = BytesStart::new("xsd:element");
558        element.push_attribute(("name", *type_name));
559        element.push_attribute(("type", "gml:AbstractFeatureType"));
560        element.push_attribute(("substitutionGroup", "gml:AbstractFeature"));
561
562        writer
563            .write_event(Event::Empty(element))
564            .map_err(|e| ServiceError::Xml(e.to_string()))?;
565    }
566
567    writer
568        .write_event(Event::End(BytesEnd::new("xsd:schema")))
569        .map_err(|e| ServiceError::Xml(e.to_string()))?;
570
571    let xml = String::from_utf8(writer.into_inner().into_inner())
572        .map_err(|e| ServiceError::Xml(e.to_string()))?;
573
574    Ok(([(header::CONTENT_TYPE, "application/xml")], xml).into_response())
575}
576
577/// Load features from file
578fn load_features_from_file(path: &std::path::Path) -> ServiceResult<Vec<geojson::Feature>> {
579    let contents = std::fs::read_to_string(path)?;
580
581    match path.extension().and_then(|e| e.to_str()) {
582        Some("geojson") | Some("json") => {
583            let geojson: geojson::GeoJson = contents.parse()?;
584            match geojson {
585                geojson::GeoJson::FeatureCollection(fc) => Ok(fc.features),
586                geojson::GeoJson::Feature(f) => Ok(vec![f]),
587                _ => Err(ServiceError::InvalidGeoJson(
588                    "Expected FeatureCollection or Feature".to_string(),
589                )),
590            }
591        }
592        _ => Err(ServiceError::UnsupportedFormat(format!(
593            "Unsupported file format: {:?}",
594            path.extension()
595        ))),
596    }
597}
598
599/// Apply BBOX filter to features
600fn apply_bbox_filter(
601    features: Vec<geojson::Feature>,
602    bbox_str: &str,
603) -> ServiceResult<Vec<geojson::Feature>> {
604    let parts: Vec<&str> = bbox_str.split(',').collect();
605    if parts.len() < 4 {
606        return Err(ServiceError::InvalidBbox(
607            "BBOX must have at least 4 coordinates".to_string(),
608        ));
609    }
610
611    let minx: f64 = parts[0]
612        .parse()
613        .map_err(|_| ServiceError::InvalidBbox("Invalid minx".to_string()))?;
614    let miny: f64 = parts[1]
615        .parse()
616        .map_err(|_| ServiceError::InvalidBbox("Invalid miny".to_string()))?;
617    let maxx: f64 = parts[2]
618        .parse()
619        .map_err(|_| ServiceError::InvalidBbox("Invalid maxx".to_string()))?;
620    let maxy: f64 = parts[3]
621        .parse()
622        .map_err(|_| ServiceError::InvalidBbox("Invalid maxy".to_string()))?;
623
624    let filtered: Vec<_> = features
625        .into_iter()
626        .filter(|f| {
627            if let Some(ref _geometry) = f.geometry {
628                if let Some(bbox) = &f.bbox {
629                    // Use feature bbox if available
630                    bbox.len() >= 4
631                        && bbox[0] <= maxx
632                        && bbox[2] >= minx
633                        && bbox[1] <= maxy
634                        && bbox[3] >= miny
635                } else {
636                    // Simple point check for now
637                    true
638                }
639            } else {
640                false
641            }
642        })
643        .collect();
644
645    Ok(filtered)
646}
647
648/// Filter feature properties
649fn filter_properties(
650    features: Vec<geojson::Feature>,
651    property_names: &str,
652) -> ServiceResult<Vec<geojson::Feature>> {
653    let names: Vec<&str> = property_names.split(',').map(|s| s.trim()).collect();
654
655    let filtered: Vec<_> = features
656        .into_iter()
657        .map(|mut f| {
658            if let Some(ref mut props) = f.properties {
659                let filtered_props: serde_json::Map<String, serde_json::Value> = props
660                    .iter()
661                    .filter(|(k, _)| names.contains(&k.as_str()))
662                    .map(|(k, v)| (k.clone(), v.clone()))
663                    .collect();
664                f.properties = Some(filtered_props);
665            }
666            f
667        })
668        .collect();
669
670    Ok(filtered)
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::wfs::{FeatureTypeInfo, ServiceInfo};
677
678    #[tokio::test]
679    async fn test_describe_feature_type() -> Result<(), Box<dyn std::error::Error>> {
680        let info = ServiceInfo {
681            title: "Test WFS".to_string(),
682            abstract_text: None,
683            provider: "COOLJAPAN OU".to_string(),
684            service_url: "http://localhost/wfs".to_string(),
685            versions: vec!["2.0.0".to_string()],
686        };
687
688        let state = WfsState::new(info);
689
690        let ft = FeatureTypeInfo {
691            name: "test_layer".to_string(),
692            title: "Test Layer".to_string(),
693            abstract_text: None,
694            default_crs: "EPSG:4326".to_string(),
695            other_crs: vec![],
696            bbox: None,
697            source: FeatureSource::Memory(vec![]),
698        };
699
700        state.add_feature_type(ft)?;
701
702        let params = serde_json::json!({
703            "TYPENAMES": "test_layer",
704            "OUTPUTFORMAT": "application/xml"
705        });
706
707        let response = handle_describe_feature_type(&state, "2.0.0", &params).await?;
708
709        let (parts, _) = response.into_parts();
710        assert_eq!(
711            parts
712                .headers
713                .get(header::CONTENT_TYPE)
714                .and_then(|h| h.to_str().ok()),
715            Some("application/xml")
716        );
717        Ok(())
718    }
719
720    #[test]
721    fn test_bbox_parsing() {
722        let features = vec![];
723        let result = apply_bbox_filter(features, "-180,-90,180,90");
724        assert!(result.is_ok());
725
726        let result = apply_bbox_filter(vec![], "invalid");
727        assert!(result.is_err());
728    }
729}