Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/data/src/ogc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use serde_json::value::RawValue;

#[path = "ogc_info.rs"]
mod info;
pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, answer_text, read_feature_info, wms_layer_of};
pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, answer_text, may_answer_in_another_format, read_feature_info, wms_layer_of};

/// What a layer of a service becomes.
#[derive(Clone, Debug, PartialEq)]
Expand Down
161 changes: 144 additions & 17 deletions crates/data/src/ogc_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,35 @@ impl FeatureInfo {

/// The answer format asked for: GeoJSON, then GML, then other XML, plain text and HTML.
pub fn info_format(&self) -> Option<&str> {
let rank = |f: &str| {
let f = f.to_ascii_lowercase();
if f.contains("geojson") || f.contains("geo+json") || f == "application/json" {
0
} else if f.contains("gml") {
1
} else if f.contains("xml") && !f.contains("html") {
2
} else if f.starts_with("text/plain") {
3
} else if f.contains("html") {
4
} else {
5
}
};
self.formats.iter().filter(|f| rank(f) < 5).min_by_key(|f| rank(f)).map(String::as_str)
self.formats.iter().filter(|f| format_rank(f) < 5).min_by_key(|f| format_rank(f)).map(String::as_str)
}

/// The answer format (one the layer offers) a request of the layer asked in: its INFO_FORMAT.
fn format_of(&self, request: &str) -> Option<&str> {
let asked = query_value(request, "info_format")?;
self.formats.iter().find(|f| super::encode(f).eq_ignore_ascii_case(asked)).map(String::as_str)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (confidence: medium). answered trims formats to the format that answered and the ones after it, so after that format_of can no longer find the format of an earlier request. An error to a request still in flight in an earlier format (for example a GeoJSON request sent before the layer learned GML) then makes retry_after return None, and the error is shown instead of retried. A fix would keep the full list in formats and track the chosen format separately, or accept the stale error and say so in a test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the case can't come up. In the app, each popup asks a layer through one chain (request, then the retry after an error), and a new click replaces the popup, so the old chain's answers are dropped unread. The command line asks one request at a time. So no request in an earlier format is still in flight once the layer has moved to a later one, and format_of always finds the format of the request it is given.

}

/// After the service answered `request` with its error: the request in the formats read after
/// the one it asked in (those of its kind left out too: a service whose GeoJSON fails for a
/// layer offers it under two names). `None` when no other format is left.
pub fn retry_after(&self, request: &str) -> Option<FeatureInfo> {
let rank = format_rank(self.format_of(request)?);
let formats: Vec<String> = self.formats.iter().filter(|f| format_rank(f) > rank).cloned().collect();
let next = FeatureInfo { formats, ..self.clone() };
next.info_format().is_some().then_some(next)
}

/// After the service answered `request`: when it asked in a format read after the layer's (a
/// retry that worked), the layer asking in that one from then on, the formats before it left
/// out. `None` when the layer keeps its request.
pub fn answered(&self, request: &str) -> Option<FeatureInfo> {
let rank = format_rank(self.format_of(request)?);
if self.info_format().is_none_or(|f| format_rank(f) >= rank) {
return None;
}
let formats: Vec<String> = self.formats.iter().filter(|f| format_rank(f) >= rank).cloned().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (confidence: medium, design). One successful answer in a later format permanently drops every earlier format for the session. A transient service exception on GeoJSON (for example "Internal server error", which carries no code, so may_answer_in_another_format retries it) followed by a GML answer will move the layer to GML for good, even though GeoJSON would work again. This matches the stated goal of the PR, but it's worth deciding whether a retried success should only change the layer after it has been seen to recur, or whether the session-long switch is acceptable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: the layer moves to a later format only after its earlier format failed with the service's error and the later one answered, which is what this PR is for. It holds for the session only. A project reopened, or the layer added again, reads the capabilities and starts from the preferred format. Waiting for a second failure would mean every click on such a service makes a failing request first.

Some(FeatureInfo { formats, ..self.clone() })
}

/// The GetFeatureInfo address for a point (`x`, `y` in Web Mercator metres) of a map drawn at
Expand Down Expand Up @@ -204,6 +216,58 @@ pub fn answer_text(body: &[u8]) -> Result<Cow<'_, str>, String> {
}
}

/// An answer format's place in [`FeatureInfo::info_format`]'s order: 5 for one not read.
fn format_rank(f: &str) -> u8 {
let f = f.to_ascii_lowercase();
if f.contains("geojson") || f.contains("geo+json") || f == "application/json" {
0
} else if f.contains("gml") {
1
} else if f.contains("xml") && !f.contains("html") {
2
} else if f.starts_with("text/plain") {
3
} else if f.contains("html") {
4
} else {
5
}
}

/// Exception codes (WMS 1.3.0 Annex E, OWS) that another answer format won't change.
const NOT_THE_FORMAT: [&str; 10] = [
"LayerNotDefined",
"LayerNotQueryable",
"InvalidPoint",
"InvalidCRS",
"InvalidSRS",
"StyleNotDefined",
"MissingDimensionValue",
"InvalidDimensionValue",
"CurrentUpdateSequence",
"InvalidUpdateSequence",
];

/// Whether a GetFeatureInfo answer is the service's error that another answer format may not meet
/// (MapServer's GeoJSON output fails for some layers its GML answers): an exception report
/// without a code saying the layer, the point or the request is at fault.
pub fn may_answer_in_another_format(body: &[u8]) -> bool {
let Ok(text) = answer_text(body) else { return false };
let text = text.trim_start_matches('\u{feff}').trim();
if !text.starts_with('<') {
return false;
}
let Ok(doc) = xml(text, 1_000_000) else { return false };
let root = doc.root_element();
matches!(local(&root), "ServiceExceptionReport" | "ExceptionReport")
&& !root.descendants().any(|n| {
n.is_element()
&& ["code", "exceptionCode"]
.iter()
.any(|a| n.attribute(*a).is_some_and(|c| NOT_THE_FORMAT.iter().any(|k| k.eq_ignore_ascii_case(c))))
})
}

/// Reads a GetFeatureInfo answer, whatever its format (found from the answer itself): its
/// features, none when nothing is at the point, or the service's exception as the error.
pub fn read_feature_info(body: &[u8]) -> Result<Vec<InfoFeature>, String> {
Expand Down Expand Up @@ -629,6 +693,69 @@ mod tests {
assert!(read_feature_info(&[0x89, b'P', b'N', b'G', 0xff, 0xfe]).is_err());
}

#[test]
fn a_format_the_service_fails_in_gives_way_to_the_next() {
// swisstopo's MapServer: GeoJSON fails for some layers ("OGR_DS_CreateLayer failed … with
// driver 'GEOJSON'"), its GML answers.
let ms = br#"<?xml version='1.0' encoding="UTF-8" standalone="no" ?>
<ServiceExceptionReport version="1.3.0" xmlns="http://www.opengis.net/ogc"><ServiceException>msOGRWriteFromQuery(): General error message.</ServiceException></ServiceExceptionReport>"#;
assert!(may_answer_in_another_format(ms));
assert!(may_answer_in_another_format(br#"<!-- x --><ows:ExceptionReport xmlns:ows="http://www.opengis.net/ows/1.1"/>"#));
assert!(may_answer_in_another_format("\u{feff}<ServiceExceptionReport/>".as_bytes()));
let invalid_format =
br#"<ServiceExceptionReport><ServiceException code="InvalidFormat">no</ServiceException></ServiceExceptionReport>"#;
assert!(may_answer_in_another_format(invalid_format));
// An error no format changes: the layer, the point.
for code in ["LayerNotQueryable", "InvalidPoint"] {
let e = format!(r#"<ServiceExceptionReport><ServiceException code="{code}">no</ServiceException></ServiceExceptionReport>"#);
assert!(!may_answer_in_another_format(e.as_bytes()), "{code}");
}
let ows = br#"<ows:ExceptionReport xmlns:ows="http://www.opengis.net/ows/1.1"><ows:Exception exceptionCode="LayerNotDefined"/></ows:ExceptionReport>"#;
assert!(!may_answer_in_another_format(ows));
// Features, empty answers, text and HTML that mention exceptions aren't errors.
assert!(!may_answer_in_another_format(
br#"<msGMLOutput><r_layer><r_feature><n>ServiceExceptionReport</n></r_feature></r_layer></msGMLOutput>"#
));
assert!(!may_answer_in_another_format(br#"{"type": "FeatureCollection", "features": []}"#));
assert!(!may_answer_in_another_format(b"Search returned no results. <ServiceExceptionReport>"));
assert!(!may_answer_in_another_format(b""));

let formats = ["application/json", "application/json; subtype=geojson", "application/vnd.ogc.gml", "text/plain", "text/html"];
let fi = FeatureInfo {
url: "https://wms.example.org/".into(),
version: "1.3.0".into(),
layer: "lines".into(),
map_format: "image/png".into(),
formats: formats.iter().map(|f| f.to_string()).collect(),
};
let ask = |fi: &FeatureInfo| fi.request(971_712.0, 6_021_494.0, 2.0).unwrap();
assert_eq!(fi.info_format(), Some("application/json"));
// Both names of GeoJSON go, then GML, then plain text, then HTML; nothing after the last.
let gml = fi.retry_after(&ask(&fi)).unwrap();
assert_eq!(gml.info_format(), Some("application/vnd.ogc.gml"));
assert_eq!(gml.formats, ["application/vnd.ogc.gml", "text/plain", "text/html"]);
assert_eq!(gml.layer, "lines");
assert!(ask(&gml).contains("&INFO_FORMAT=application%2Fvnd.ogc.gml&"));
let plain = fi.retry_after(&ask(&gml)).unwrap();
assert_eq!(plain.info_format(), Some("text/plain"));
let html = fi.retry_after(&ask(&plain)).unwrap();
assert_eq!(html.info_format(), Some("text/html"));
assert!(fi.retry_after(&ask(&html)).is_none());
// The format comes from the request answered, not from the layer: a second error to a
// GeoJSON request leads to GML again.
assert_eq!(fi.retry_after(&ask(&fi)), Some(gml.clone()));
// A request in no format the layer offers isn't retried.
assert!(fi.retry_after("https://wms.example.org/?INFO_FORMAT=text%2Fcsv").is_none());
assert!(fi.retry_after("https://wms.example.org/").is_none());
// Answered in GML, the layer asks in GML; answered in its own format, it keeps its request.
assert_eq!(fi.answered(&ask(&gml)), Some(gml.clone()));
assert!(fi.answered(&ask(&fi)).is_none());
assert!(gml.answered(&ask(&fi)).is_none());
// Formats RustGIS doesn't read don't count as left.
let odd = FeatureInfo { formats: vec!["text/xml".into(), "image/png".into()], ..fi.clone() };
assert!(odd.retry_after(&ask(&odd)).is_none());
}

#[test]
fn text_and_html_answers() {
let geoserver = "Results for FeatureType 'roads':\n--------------------------------------------\nname = Main St\nlanes = 2\n--------------------------------------------\nname = Side St\nlanes = 1\n";
Expand Down
78 changes: 67 additions & 11 deletions crates/engine/src/cmd/select_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const MAX_TEXT: usize = 2 * ogc::MAX_INFO_BYTES;
pub(super) fn commands() -> Vec<C> {
vec![
C::new("identify.featureInfo", "Identify WMS Layer", "Home › Inspect", feature_info).view().params(
r#"{"layer": "a WMS tile layer's name", "lon"?, "lat"?: the point (for a request), "text"?: "the GetFeatureInfo answer, when already fetched (identify lists the requests)", "capabilities"?: "the service's capabilities document, when the layer doesn't know its request (identify then asks for it)", "request"?: bool (only say what to fetch next: {url} or {capabilities})} → results, as identify's (with "tileLayer"), or with request, what to fetch"#,
r#"{"layer": "a WMS tile layer's name", "lon"?, "lat"?: the point (for a request), "text"?: "the GetFeatureInfo answer, when already fetched (identify lists the requests)", "url"?: "the request text answers: with lon and lat, the service's error in its format gives the request in the next format the layer offers ({url}), and features answered in a later format keep the layer asking in it", "capabilities"?: "the service's capabilities document, when the layer doesn't know its request (identify then asks for it)", "request"?: bool (only say what to fetch next: {url} or {capabilities})} → results, as identify's (with "tileLayer"), or with request or after an error, what to fetch"#,
),
]
}
Expand Down Expand Up @@ -67,19 +67,23 @@ fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord,
return None;
}
match &t.feature_info {
// The request's address comes from the capabilities (hostile input): it must not reach
// into the local network from a service on the web.
Some(fi) => fi
.request(at.x, at.y, m_per_px)
.filter(|url| crate::render::rustgis_tiles::address::check(url, Some(&t.url)).is_ok())
.map(|url| json!({"tileLayer": t.name, "url": url})),
Some(fi) => ask(t, fi, at, m_per_px),
// The capabilities address comes from the layer's template (a project's, untrusted too).
None => ogc::wms_layer_of(&t.url)
.filter(|(caps, _)| crate::render::rustgis_tiles::address::check(caps, Some(&t.url)).is_ok())
.map(|(caps, _)| json!({"tileLayer": t.name, "capabilities": caps})),
}
}

/// A WMS tile layer's GetFeatureInfo request for a point, in `fi`'s terms.
fn ask(t: &rustgis_map::tiles::TileLayer, fi: &FeatureInfo, at: Coord, m_per_px: f64) -> Option<Value> {
// The request's address comes from the capabilities (hostile input): it must not reach into
// the local network from a service on the web.
fi.request(at.x, at.y, m_per_px)
.filter(|url| crate::render::rustgis_tiles::address::check(url, Some(&t.url)).is_ok())
.map(|url| json!({"tileLayer": t.name, "url": url}))
}

/// The WMS tile layers drawn at a point to ask what is there, topmost first (only the topmost
/// when `first_layer`), each with what to fetch.
pub(super) fn requests(s: &Session, lon: f64, lat: f64, first_layer: bool) -> Vec<Value> {
Expand Down Expand Up @@ -157,7 +161,26 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult {
if text.len() > MAX_TEXT {
return Err(named(format!("the answer is larger than {} MB", MAX_TEXT >> 20)));
}
return Ok(results(&name, ogc::read_feature_info(text.as_bytes()).map_err(named)?));
let asked = ps(p, "url");
return match ogc::read_feature_info(text.as_bytes()) {
Ok(features) => {
if let Some(request) = asked {
answered(s, index, request);
}
Ok(results(&name, features))
}
// The service's error in the format asked in: the request in the next, for the point.
Err(e) => {
let point = f(p, "lon").zip(f(p, "lat")).and_then(|(lon, lat)| merc_at(s, lon, lat).map(|m| (lon, lat, m)));
let retry = match (asked, point) {
(Some(request), Some((lon, lat, (at, m_per_px)))) if ogc::may_answer_in_another_format(text.as_bytes()) => {
retry(s, index, request, lon, lat, at, m_per_px)
}
_ => None,
};
retry.ok_or_else(|| named(e))
}
};
}
let lon = f(p, "lon").ok_or("lon is required")?;
let lat = f(p, "lat").ok_or("lat is required")?;
Expand All @@ -182,9 +205,42 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult {
learn(s, index, &fetch(&caps)?).map_err(named)?;
step = next(s);
}
match step.as_ref().and_then(|v| v["url"].as_str()) {
Some(url) => Ok(results(&name, ogc::read_feature_info(&fetch(url)?).map_err(named)?)),
None => Ok(json!({"results": []})),
// Each of the service's errors asks in a format read after the last, so this ends.
loop {
let Some(url) = step.as_ref().and_then(|v| v["url"].as_str()).map(str::to_string) else {
return Ok(json!({"results": []}));
};
let answer = fetch(&url)?;
match ogc::read_feature_info(&answer) {
Ok(features) => {
answered(s, index, &url);
return Ok(results(&name, features));
}
Err(e) => match retry(s, index, &url, lon, lat, at, m_per_px) {
Some(next) if ogc::may_answer_in_another_format(&answer) => step = Some(next),
_ => return Err(named(e)),
},
}
}
}
}

/// After the service answered `request` with its error: the request in the next answer format
/// the layer offers, when there is one. The layer keeps its own until another format answers.
fn retry(s: &Session, index: usize, request: &str, lon: f64, lat: f64, at: Coord, m_per_px: f64) -> Option<Value> {
let t = s.map().tiles.get(index)?;
if t.bounds.is_some_and(|[w, s, e, n]| !(w <= lon && lon <= e && s <= lat && lat <= n)) {
return None;
}
let next = t.feature_info.as_ref()?.retry_after(request)?;
ask(t, &next, at, m_per_px)
}

/// After the service answered `request` with features (or none): a layer asked in a later format
/// than its own (a retry that worked) asks in that one for the rest of the session.
fn answered(s: &mut Session, index: usize, request: &str) {
let Some(t) = s.map_mut().tiles.get_mut(index) else { return };
if let Some(next) = t.feature_info.as_ref().and_then(|fi| fi.answered(request)) {
t.feature_info = Some(next);
}
}
Loading
Loading