zebflow 0.9.1

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
use serde_json::Value;

use crate::mapserver::infra::bbox::{feature_intersects_bbox, normalize_bbox};
use crate::mapserver::infra::source::SourceAdapter;
use crate::mapserver::publish::manifest::PublishedLayerManifest;

use super::{ResolveRequest, ResolveResponse, prune_feature_properties};

pub fn resolve_feature_collection_from_geojson_file(
    manifest: &PublishedLayerManifest,
    request: &ResolveRequest,
) -> Result<ResolveResponse, String> {
    let source_value = crate::mapserver::infra::source::geojson_file::GeoJsonFileSource::load(
        &manifest.source_ref,
    )?;
    resolve_feature_collection_from_value(manifest, request, source_value)
}

pub fn resolve_feature_collection_from_value(
    manifest: &PublishedLayerManifest,
    request: &ResolveRequest,
    source_value: Value,
) -> Result<ResolveResponse, String> {
    if manifest.bbox_required && request.bbox.is_none() {
        return Err("bbox is required for this layer".to_string());
    }
    let Some(obj) = source_value.as_object() else {
        return Err("source is not a GeoJSON object".to_string());
    };
    if obj.get("type").and_then(Value::as_str) != Some("FeatureCollection") {
        return Err("source is not a GeoJSON FeatureCollection".to_string());
    }
    let Some(features) = obj.get("features").and_then(Value::as_array) else {
        return Err("source GeoJSON missing features array".to_string());
    };
    let bbox = request.bbox.and_then(normalize_bbox);
    let hard_limit = match request.limit {
        Some(l) => l,
        None => manifest.max_features,
    };
    let parsed_filter = request
        .filter
        .as_deref()
        .map(super::filter_dsl::parse_filter)
        .transpose()
        .ok()
        .flatten();
    let mut out = Vec::new();
    let mut matched = 0usize;
    for feature in features {
        let intersects = bbox
            .map(|bbox| feature_intersects_bbox(feature, bbox))
            .unwrap_or(true);
        if !intersects {
            continue;
        }
        if let Some(ref filter) = parsed_filter {
            if !super::filter_dsl::matches_geojson_feature(filter, feature) {
                continue;
            }
        }
        matched += 1;
        if out.len() < hard_limit {
            out.push(prune_feature_properties(
                feature.clone(),
                &manifest.allowed_properties,
            ));
        }
    }
    Ok(ResolveResponse {
        layer: manifest.layer_id.clone(),
        count: out.len(),
        truncated: matched > out.len(),
        features: out,
    })
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use crate::mapserver::publish::manifest::{PublishedLayerManifest, SourceKind};

    use super::*;

    #[test]
    fn feature_collection_bbox_filter_and_limit_work() {
        let manifest = PublishedLayerManifest {
            layer_id: "adm1".to_string(),
            path: "/adm1".to_string(),
            source_kind: SourceKind::GeoJsonFile,
            source_ref: "/tmp/adm1.geojson".to_string(),
            mode: "features".to_string(),
            min_zoom: None,
            max_zoom: None,
            bbox_required: true,
            max_features: 1,
            allowed_properties: vec!["name".to_string()],
            style: None,
            filter: None,
            function_slug: None,
            cache_ttl_secs: None,
        };
        let req = ResolveRequest {
            layer_id: "adm1".to_string(),
            bbox: Some([106.0, -7.0, 108.0, -5.0]),
            zoom: Some(6),
            limit: Some(1), // Limit enforced by caller (web handler caps for GeoJSON)
            filter: None,
        };
        let source = json!({
            "type": "FeatureCollection",
            "features": [
                {
                    "type": "Feature",
                    "properties": { "name": "A", "code": "1" },
                    "geometry": {
                        "type": "Polygon",
                        "coordinates": [[[106.0, -6.5], [107.0, -6.5], [107.0, -6.0], [106.0, -6.0], [106.0, -6.5]]]
                    }
                },
                {
                    "type": "Feature",
                    "properties": { "name": "B", "code": "2" },
                    "geometry": {
                        "type": "Polygon",
                        "coordinates": [[[106.5, -6.4], [107.5, -6.4], [107.5, -5.9], [106.5, -5.9], [106.5, -6.4]]]
                    }
                },
                {
                    "type": "Feature",
                    "properties": { "name": "Far", "code": "3" },
                    "geometry": {
                        "type": "Polygon",
                        "coordinates": [[[120.0, 0.0], [121.0, 0.0], [121.0, 1.0], [120.0, 1.0], [120.0, 0.0]]]
                    }
                }
            ]
        });
        let out = resolve_feature_collection_from_value(&manifest, &req, source.clone()).unwrap();
        assert_eq!(out.layer, "adm1");
        assert_eq!(out.count, 1);
        assert!(out.truncated);
        assert_eq!(
            out.features[0],
            json!({
                "type": "Feature",
                "properties": { "name": "A" },
                "geometry": {
                    "type": "Polygon",
                    "coordinates": [[[106.0, -6.5], [107.0, -6.5], [107.0, -6.0], [106.0, -6.0], [106.0, -6.5]]]
                }
            })
        );

        // Contract `MapPublishManifest`: an empty `allowed_properties` serves
        // geometry only. Nothing about the source may leak through the absence
        // of a choice.
        let closed = PublishedLayerManifest {
            allowed_properties: Vec::new(),
            max_features: 10,
            ..manifest.clone()
        };
        let closed_req = ResolveRequest {
            limit: Some(10),
            ..req.clone()
        };
        let out = resolve_feature_collection_from_value(&closed, &closed_req, source).unwrap();
        assert_eq!(out.count, 2);
        for feature in &out.features {
            assert_eq!(feature["properties"], json!({}));
            assert!(feature["geometry"].is_object());
        }
    }
}