• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geo-engine / geoengine / 13809415963

12 Mar 2025 10:42AM UTC coverage: 90.026% (-0.05%) from 90.076%
13809415963

Pull #1013

github

web-flow
Merge b51e2554c into c96026921
Pull Request #1013: Update-utoipa

787 of 935 new or added lines in 41 files covered. (84.17%)

28 existing lines in 10 files now uncovered.

125995 of 139954 relevant lines covered (90.03%)

57510.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.42
/services/src/api/handlers/plots.rs
1
use crate::api::model::datatypes::TimeInterval;
2
use crate::api::ogc::util::{parse_bbox, parse_time};
3
use crate::config;
4
use crate::contexts::{ApplicationContext, SessionContext};
5
use crate::error;
6
use crate::error::Result;
7
use crate::util::parsing::parse_spatial_resolution;
8
use crate::util::server::connection_closed;
9
use crate::workflows::registry::WorkflowRegistry;
10
use crate::workflows::workflow::WorkflowId;
11
use actix_web::{web, FromRequest, HttpRequest, Responder};
12
use base64::Engine;
13
use geoengine_datatypes::operations::reproject::reproject_query;
14
use geoengine_datatypes::plots::PlotOutputFormat;
15
use geoengine_datatypes::primitives::{
16
    BoundingBox2D, ColumnSelection, SpatialResolution, VectorQueryRectangle,
17
};
18
use geoengine_datatypes::spatial_reference::SpatialReference;
19
use geoengine_operators::engine::{
20
    QueryContext, ResultDescriptor, TypedPlotQueryProcessor, WorkflowOperatorPath,
21
};
22
use geoengine_operators::util::abortable_query_execution;
23
use serde::{Deserialize, Serialize};
24
use snafu::ResultExt;
25
use std::time::Duration;
26
use utoipa::{IntoParams, ToSchema};
27
use uuid::Uuid;
28

29
pub(crate) fn init_plot_routes<C>(cfg: &mut web::ServiceConfig)
342✔
30
where
342✔
31
    C: ApplicationContext,
342✔
32
    C::Session: FromRequest,
342✔
33
{
342✔
34
    cfg.service(web::resource("/plot/{id}").route(web::get().to(get_plot_handler::<C>)));
342✔
35
}
342✔
36

UNCOV
37
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, IntoParams)]
×
38
#[serde(rename_all = "camelCase")]
39
pub(crate) struct GetPlot {
40
    #[serde(deserialize_with = "parse_bbox")]
41
    #[param(example = "0,-0.3,0.2,0", value_type = String)]
42
    pub bbox: BoundingBox2D,
43
    #[param(example = "EPSG:4326", value_type = Option<String>)]
44
    pub crs: Option<SpatialReference>,
45
    #[serde(deserialize_with = "parse_time")]
46
    #[param(example = "2020-01-01T00:00:00.0Z", value_type = String)]
47
    pub time: TimeInterval,
48
    #[serde(deserialize_with = "parse_spatial_resolution")]
49
    #[param(example = "0.1,0.1", value_type = String)]
50
    pub spatial_resolution: SpatialResolution,
51
}
52

53
/// Generates a plot.
54
///
55
/// # Example
56
///
57
/// 1. Upload the file `plain_data.csv` with the following content:
58
///
59
/// ```csv
60
/// a
61
/// 1
62
/// 2
63
/// ```
64
/// 2. Create a dataset from it using the "Plain Data" example at `/dataset`.
65
/// 3. Create a statistics workflow using the "Statistics Plot" example at `/workflow`.
66
/// 4. Generate the plot with this handler.
67
#[utoipa::path(
32✔
68
    tag = "Plots",
32✔
69
    get,
32✔
70
    path = "/plot/{id}",
32✔
71
    responses(
32✔
72
        (status = 200, description = "OK", body = WrappedPlotOutput,
32✔
73
            example = json!({
32✔
74
                "outputFormat": "JsonPlain",
32✔
75
                "plotType": "Statistics",
32✔
76
                "data": {
32✔
77
                    "a": {
32✔
78
                        "max": 2.0,
32✔
79
                        "mean": 1.5,
32✔
80
                        "min": 1.0,
32✔
81
                        "stddev": 0.5,
32✔
82
                        "validCount": 2,
32✔
83
                        "valueCount": 2
32✔
84
                    }
32✔
85
                }
32✔
86
           })
32✔
87
        )
32✔
88
    ),
32✔
89
    params(
32✔
90
        GetPlot,
32✔
91
        ("id", description = "Workflow id")
32✔
92
    ),
32✔
93
    security(
32✔
94
        ("session_token" = [])
32✔
95
    )
32✔
96
)]
32✔
97
async fn get_plot_handler<C: ApplicationContext>(
2✔
98
    req: HttpRequest,
2✔
99
    id: web::Path<Uuid>,
2✔
100
    params: web::Query<GetPlot>,
2✔
101
    session: C::Session,
2✔
102
    app_ctx: web::Data<C>,
2✔
103
) -> Result<impl Responder> {
2✔
104
    let conn_closed = connection_closed(
2✔
105
        &req,
2✔
106
        config::get_config_element::<config::Plots>()?
2✔
107
            .request_timeout_seconds
108
            .map(Duration::from_secs),
2✔
109
    );
2✔
110

2✔
111
    let ctx = app_ctx.session_context(session);
2✔
112
    let workflow_id = WorkflowId(id.into_inner());
2✔
113
    let workflow = ctx.db().load_workflow(&workflow_id).await?;
2✔
114

115
    let operator = workflow.operator.get_plot()?;
2✔
116

117
    let execution_context = ctx.execution_context()?;
2✔
118

119
    let workflow_operator_path_root = WorkflowOperatorPath::initialize_root();
2✔
120

121
    let initialized = operator
2✔
122
        .initialize(workflow_operator_path_root, &execution_context)
2✔
123
        .await?;
2✔
124

125
    // handle request and workflow crs matching
126
    let workflow_spatial_ref: Option<SpatialReference> =
2✔
127
        initialized.result_descriptor().spatial_reference().into();
2✔
128
    let workflow_spatial_ref = workflow_spatial_ref.ok_or(error::Error::InvalidSpatialReference)?;
2✔
129

130
    // TODO: use a default spatial reference if it is not set?
131
    let request_spatial_ref: SpatialReference =
2✔
132
        params.crs.ok_or(error::Error::MissingSpatialReference)?;
2✔
133

134
    let query_rect = VectorQueryRectangle {
2✔
135
        spatial_bounds: params.bbox,
2✔
136
        time_interval: params.time.into(),
2✔
137
        spatial_resolution: params.spatial_resolution,
2✔
138
        attributes: ColumnSelection::all(),
2✔
139
    };
2✔
140

141
    let query_rect = if request_spatial_ref == workflow_spatial_ref {
2✔
142
        Some(query_rect)
2✔
143
    } else {
144
        reproject_query(query_rect, workflow_spatial_ref, request_spatial_ref)?
×
145
    };
146

147
    let Some(query_rect) = query_rect else {
2✔
148
        return Err(error::Error::UnresolvableQueryBoundingBox2DInSrs {
×
149
            query_bbox: params.bbox.into(),
×
150
            query_srs: workflow_spatial_ref.into(),
×
151
        });
×
152
    };
153

154
    let processor = initialized.query_processor()?;
2✔
155

156
    let mut query_ctx = ctx.query_context(workflow_id.0, Uuid::new_v4())?;
2✔
157

158
    let query_abort_trigger = query_ctx.abort_trigger()?;
2✔
159

160
    let output_format = PlotOutputFormat::from(&processor);
2✔
161
    let plot_type = processor.plot_type();
2✔
162

163
    let data = match processor {
2✔
164
        TypedPlotQueryProcessor::JsonPlain(processor) => {
1✔
165
            let json = processor.plot_query(query_rect.into(), &query_ctx);
1✔
166
            abortable_query_execution(json, conn_closed, query_abort_trigger).await?
1✔
167
        }
168
        TypedPlotQueryProcessor::JsonVega(processor) => {
1✔
169
            let chart = processor.plot_query(query_rect.into(), &query_ctx);
1✔
170
            let chart = abortable_query_execution(chart, conn_closed, query_abort_trigger).await;
1✔
171
            let chart = chart?;
1✔
172

173
            serde_json::to_value(chart).context(error::SerdeJson)?
1✔
174
        }
175
        TypedPlotQueryProcessor::ImagePng(processor) => {
×
176
            let png_bytes = processor.plot_query(query_rect.into(), &query_ctx);
×
177
            let png_bytes =
×
178
                abortable_query_execution(png_bytes, conn_closed, query_abort_trigger).await;
×
179
            let png_bytes = png_bytes?;
×
180

181
            let data_uri = format!(
×
182
                "data:image/png;base64,{}",
×
183
                base64::engine::general_purpose::STANDARD.encode(png_bytes)
×
184
            );
×
185

×
186
            serde_json::to_value(data_uri).context(error::SerdeJson)?
×
187
        }
188
    };
189

190
    let output = WrappedPlotOutput {
2✔
191
        output_format,
2✔
192
        plot_type,
2✔
193
        data,
2✔
194
    };
2✔
195

2✔
196
    Ok(web::Json(output))
2✔
197
}
2✔
198

199
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
28✔
200
#[serde(rename_all = "camelCase")]
201
pub struct WrappedPlotOutput {
202
    #[schema(value_type = crate::api::model::datatypes::PlotOutputFormat)]
203
    output_format: PlotOutputFormat,
204
    plot_type: &'static str,
205
    #[schema(value_type = Object)]
206
    data: serde_json::Value,
207
}
208

209
#[cfg(test)]
210
mod tests {
211
    use super::*;
212
    use crate::contexts::PostgresContext;
213
    use crate::contexts::Session;
214
    use crate::ge_context;
215
    use crate::users::UserAuth;
216
    use crate::util::tests::{
217
        check_allowed_http_methods, read_body_json, read_body_string, send_test_request,
218
    };
219
    use crate::workflows::workflow::Workflow;
220
    use actix_web;
221
    use actix_web::dev::ServiceResponse;
222
    use actix_web::http::{header, Method};
223
    use actix_web_httpauth::headers::authorization::Bearer;
224
    use geoengine_datatypes::primitives::CacheHint;
225
    use geoengine_datatypes::primitives::DateTime;
226
    use geoengine_datatypes::raster::{
227
        Grid2D, RasterDataType, RasterTile2D, TileInformation, TilingSpecification,
228
    };
229
    use geoengine_datatypes::spatial_reference::SpatialReference;
230
    use geoengine_datatypes::util::test::TestDefault;
231
    use geoengine_operators::engine::{
232
        PlotOperator, RasterBandDescriptors, RasterOperator, RasterResultDescriptor,
233
    };
234
    use geoengine_operators::mock::{MockRasterSource, MockRasterSourceParams};
235
    use geoengine_operators::plot::{
236
        Histogram, HistogramBounds, HistogramBuckets, HistogramParams, Statistics, StatisticsParams,
237
    };
238
    use serde_json::{json, Value};
239
    use tokio_postgres::NoTls;
240

241
    fn example_raster_source() -> Box<dyn RasterOperator> {
10✔
242
        MockRasterSource {
10✔
243
            params: MockRasterSourceParams {
10✔
244
                data: vec![RasterTile2D::new_with_tile_info(
10✔
245
                    geoengine_datatypes::primitives::TimeInterval::default(),
10✔
246
                    TileInformation {
10✔
247
                        global_geo_transform: TestDefault::test_default(),
10✔
248
                        global_tile_position: [0, 0].into(),
10✔
249
                        tile_size_in_pixels: [3, 2].into(),
10✔
250
                    },
10✔
251
                    0,
10✔
252
                    Grid2D::new([3, 2].into(), vec![1, 2, 3, 4, 5, 6])
10✔
253
                        .unwrap()
10✔
254
                        .into(),
10✔
255
                    CacheHint::default(),
10✔
256
                )],
10✔
257
                result_descriptor: RasterResultDescriptor {
10✔
258
                    data_type: RasterDataType::U8,
10✔
259
                    spatial_reference: SpatialReference::epsg_4326().into(),
10✔
260
                    time: None,
10✔
261
                    bbox: None,
10✔
262
                    resolution: None,
10✔
263
                    bands: RasterBandDescriptors::new_single_band(),
10✔
264
                },
10✔
265
            },
10✔
266
        }
10✔
267
        .boxed()
10✔
268
    }
10✔
269

270
    fn json_tiling_spec() -> TilingSpecification {
1✔
271
        TilingSpecification::new([0.0, 0.0].into(), [3, 2].into())
1✔
272
    }
1✔
273

274
    #[ge_context::test(tiling_spec = "json_tiling_spec")]
1✔
275
    async fn json(app_ctx: PostgresContext<NoTls>) {
1✔
276
        let session = app_ctx.create_anonymous_session().await.unwrap();
1✔
277

1✔
278
        let session_id = session.id();
1✔
279

1✔
280
        let workflow = Workflow {
1✔
281
            operator: Statistics {
1✔
282
                params: StatisticsParams {
1✔
283
                    column_names: vec![],
1✔
284
                    percentiles: vec![],
1✔
285
                },
1✔
286
                sources: vec![example_raster_source()].into(),
1✔
287
            }
1✔
288
            .boxed()
1✔
289
            .into(),
1✔
290
        };
1✔
291

292
        let id = app_ctx
1✔
293
            .session_context(session.clone())
1✔
294
            .db()
1✔
295
            .register_workflow(workflow)
1✔
296
            .await
1✔
297
            .unwrap();
1✔
298

1✔
299
        let params = &[
1✔
300
            ("bbox", "0,-0.3,0.2,0"),
1✔
301
            ("crs", "EPSG:4326"),
1✔
302
            ("time", "2020-01-01T00:00:00.0Z"),
1✔
303
            ("spatialResolution", "0.1,0.1"),
1✔
304
        ];
1✔
305
        let req = actix_web::test::TestRequest::get()
1✔
306
            .uri(&format!(
1✔
307
                "/plot/{}?{}",
1✔
308
                id,
1✔
309
                &serde_urlencoded::to_string(params).unwrap()
1✔
310
            ))
1✔
311
            .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string())));
1✔
312
        let res = send_test_request(req, app_ctx).await;
1✔
313

314
        assert_eq!(res.status(), 200);
1✔
315

316
        assert_eq!(
1✔
317
            read_body_json(res).await,
1✔
318
            json!({
1✔
319
                "outputFormat": "JsonPlain",
1✔
320
                "plotType": "Statistics",
1✔
321
                "data": {
1✔
322
                    "Raster-1": {
1✔
323
                        "valueCount": 24, // Note: this is caused by the query being a BoundingBox where the right and lower bounds are inclusive. This requires that the tiles that inculde the right and lower bounds are also produced.
1✔
324
                        "validCount": 6,
1✔
325
                        "min": 1.0,
1✔
326
                        "max": 6.0,
1✔
327
                        "mean": 3.5,
1✔
328
                        "stddev": 1.707_825_127_659_933,
1✔
329
                        "percentiles": []
1✔
330
                    }
1✔
331
                }
1✔
332
            })
1✔
333
        );
334
    }
1✔
335

336
    fn json_vega_tiling_spec() -> TilingSpecification {
1✔
337
        TilingSpecification::new([0.0, 0.0].into(), [3, 2].into())
1✔
338
    }
1✔
339

340
    #[ge_context::test(tiling_spec = "json_vega_tiling_spec")]
1✔
341
    async fn json_vega(app_ctx: PostgresContext<NoTls>) {
1✔
342
        let session = app_ctx.create_anonymous_session().await.unwrap();
1✔
343

1✔
344
        let session_id = session.id();
1✔
345

1✔
346
        let workflow = Workflow {
1✔
347
            operator: Histogram {
1✔
348
                params: HistogramParams {
1✔
349
                    attribute_name: "band".to_string(),
1✔
350
                    bounds: HistogramBounds::Values {
1✔
351
                        min: 0.0,
1✔
352
                        max: 10.0,
1✔
353
                    },
1✔
354
                    buckets: HistogramBuckets::Number { value: 4 },
1✔
355
                    interactive: false,
1✔
356
                },
1✔
357
                sources: example_raster_source().into(),
1✔
358
            }
1✔
359
            .boxed()
1✔
360
            .into(),
1✔
361
        };
1✔
362

363
        let id = app_ctx
1✔
364
            .session_context(session.clone())
1✔
365
            .db()
1✔
366
            .register_workflow(workflow)
1✔
367
            .await
1✔
368
            .unwrap();
1✔
369

1✔
370
        let params = &[
1✔
371
            ("bbox", "0,-0.3,0.2,0"),
1✔
372
            ("crs", "EPSG:4326"),
1✔
373
            ("time", "2020-01-01T00:00:00.0Z"),
1✔
374
            ("spatialResolution", "0.1,0.1"),
1✔
375
        ];
1✔
376
        let req = actix_web::test::TestRequest::get()
1✔
377
            .uri(&format!(
1✔
378
                "/plot/{}?{}",
1✔
379
                id,
1✔
380
                &serde_urlencoded::to_string(params).unwrap()
1✔
381
            ))
1✔
382
            .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string())));
1✔
383
        let res = send_test_request(req, app_ctx).await;
1✔
384

385
        assert_eq!(res.status(), 200);
1✔
386

387
        let response = serde_json::from_str::<Value>(&read_body_string(res).await).unwrap();
1✔
388

1✔
389
        assert_eq!(response["outputFormat"], "JsonVega");
1✔
390
        assert_eq!(response["plotType"], "Histogram");
1✔
391
        assert!(response["plotType"]["metadata"].is_null());
1✔
392

393
        let vega_json: Value =
1✔
394
            serde_json::from_str(response["data"]["vegaString"].as_str().unwrap()).unwrap();
1✔
395

1✔
396
        assert_eq!(
1✔
397
            vega_json,
1✔
398
            json!({
1✔
399
                "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
1✔
400
                "data": {
1✔
401
                    "values": [{
1✔
402
                        "binStart": 0.0,
1✔
403
                        "binEnd": 2.5,
1✔
404
                        "Frequency": 2
1✔
405
                    }, {
1✔
406
                        "binStart": 2.5,
1✔
407
                        "binEnd": 5.0,
1✔
408
                        "Frequency": 2
1✔
409
                    }, {
1✔
410
                        "binStart": 5.0,
1✔
411
                        "binEnd": 7.5,
1✔
412
                        "Frequency": 2
1✔
413
                    }, {
1✔
414
                        "binStart": 7.5,
1✔
415
                        "binEnd": 10.0,
1✔
416
                        "Frequency": 0
1✔
417
                    }]
1✔
418
                },
1✔
419
                "mark": "bar",
1✔
420
                "encoding": {
1✔
421
                    "x": {
1✔
422
                        "field": "binStart",
1✔
423
                        "bin": {
1✔
424
                            "binned": true,
1✔
425
                            "step": 2.5
1✔
426
                        },
1✔
427
                        "axis": {
1✔
428
                            "title": ""
1✔
429
                        }
1✔
430
                    },
1✔
431
                    "x2": {
1✔
432
                        "field": "binEnd"
1✔
433
                    },
1✔
434
                    "y": {
1✔
435
                        "field": "Frequency",
1✔
436
                        "type": "quantitative"
1✔
437
                    }
1✔
438
                }
1✔
439
            })
1✔
440
        );
1✔
441
    }
1✔
442

443
    #[test]
444
    fn deserialize_get_plot() {
1✔
445
        let params = &[
1✔
446
            ("bbox", "-180,-90,180,90"),
1✔
447
            ("crs", "EPSG:4326"),
1✔
448
            ("time", "2020-01-01T00:00:00.0Z"),
1✔
449
            ("spatialResolution", "0.1,0.1"),
1✔
450
        ];
1✔
451

1✔
452
        assert_eq!(
1✔
453
            serde_urlencoded::from_str::<GetPlot>(&serde_urlencoded::to_string(params).unwrap())
1✔
454
                .unwrap(),
1✔
455
            GetPlot {
1✔
456
                bbox: BoundingBox2D::new((-180., -90.).into(), (180., 90.).into()).unwrap(),
1✔
457
                crs: SpatialReference::epsg_4326().into(),
1✔
458
                time: geoengine_datatypes::primitives::TimeInterval::new(
1✔
459
                    DateTime::new_utc(2020, 1, 1, 0, 0, 0),
1✔
460
                    DateTime::new_utc(2020, 1, 1, 0, 0, 0),
1✔
461
                )
1✔
462
                .unwrap()
1✔
463
                .into(),
1✔
464
                spatial_resolution: SpatialResolution::zero_point_one(),
1✔
465
            }
1✔
466
        );
1✔
467
    }
1✔
468

469
    #[ge_context::test]
1✔
470
    async fn check_request_types(app_ctx: PostgresContext<NoTls>) {
1✔
471
        async fn get_workflow_json(
8✔
472
            app_ctx: PostgresContext<NoTls>,
8✔
473
            method: Method,
8✔
474
        ) -> ServiceResponse {
8✔
475
            let session = app_ctx.create_anonymous_session().await.unwrap();
8✔
476
            let ctx = app_ctx.session_context(session.clone());
8✔
477

8✔
478
            let session_id = session.id();
8✔
479

8✔
480
            let workflow = Workflow {
8✔
481
                operator: Statistics {
8✔
482
                    params: StatisticsParams {
8✔
483
                        column_names: vec![],
8✔
484
                        percentiles: vec![],
8✔
485
                    },
8✔
486
                    sources: vec![example_raster_source()].into(),
8✔
487
                }
8✔
488
                .boxed()
8✔
489
                .into(),
8✔
490
            };
8✔
491

492
            let id = ctx.db().register_workflow(workflow).await.unwrap();
8✔
493

8✔
494
            let params = &[
8✔
495
                ("bbox", "-180,-90,180,90"),
8✔
496
                ("time", "2020-01-01T00:00:00.0Z"),
8✔
497
                ("spatial_resolution", "0.1,0.1"),
8✔
498
            ];
8✔
499
            let req = actix_web::test::TestRequest::default()
8✔
500
                .method(method)
8✔
501
                .uri(&format!(
8✔
502
                    "/plot/{}?{}",
8✔
503
                    id,
8✔
504
                    &serde_urlencoded::to_string(params).unwrap()
8✔
505
                ))
8✔
506
                .append_header((header::AUTHORIZATION, Bearer::new(session_id.to_string())));
8✔
507
            send_test_request(req, app_ctx).await
8✔
508
        }
8✔
509

510
        check_allowed_http_methods(
1✔
511
            |method| get_workflow_json(app_ctx.clone(), method),
8✔
512
            &[Method::GET],
1✔
513
        )
1✔
514
        .await;
1✔
515
    }
1✔
516
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc