Skip to content

Commit

Permalink
Merge pull request #31 from meilisearch/did-you-mean
Browse files Browse the repository at this point in the history
Implement the did you mean message for the json and query parameter errors
  • Loading branch information
irevoire authored Feb 7, 2023
2 parents 59ce7cc + d049f85 commit e5794dd
Show file tree
Hide file tree
Showing 8 changed files with 360 additions and 22 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ serde-cs = { version = "0.2.4", optional = true }
actix-web = { version = "4.3.0", optional = true }
futures = { version = "0.3.25", optional = true }
deserr-internal = { version = "=0.3.0", path = "derive" }
strsim = "0.10.0"

[features]
default = ["serde-json", "serde-cs", "actix-web"]
Expand Down
4 changes: 2 additions & 2 deletions src/impls.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Map, Sequence,
Value, ValueKind, ValuePointerRef,
take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Map, Sequence, Value,
ValueKind, ValuePointerRef,
};
use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
Expand Down
101 changes: 98 additions & 3 deletions src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{convert::Infallible, fmt::Display, ops::ControlFlow};

use deserr::{ErrorKind, IntoValue, ValueKind, ValuePointerRef};

use crate::{DeserializeError, MergeWithError};
use crate::{did_you_mean, DeserializeError, MergeWithError};

#[derive(Debug, Clone)]
pub struct JsonError(String);
Expand Down Expand Up @@ -158,9 +158,11 @@ impl DeserializeError for JsonError {
}
ErrorKind::UnknownKey { key, accepted } => {
let location = location_json_description(location, " inside");

format!(
"Unknown field `{}`{location}: expected one of {}",
"Unknown field `{}`{location}: {}expected one of {}",
key,
did_you_mean(key, accepted),
accepted
.iter()
.map(|accepted| format!("`{}`", accepted))
Expand All @@ -171,8 +173,9 @@ impl DeserializeError for JsonError {
ErrorKind::UnknownValue { value, accepted } => {
let location = location_json_description(location, " at");
format!(
"Unknown value `{}`{location}: expected one of {}",
"Unknown value `{}`{location}: {}expected one of {}",
value,
did_you_mean(value, accepted),
accepted
.iter()
.map(|accepted| format!("`{}`", accepted))
Expand Down Expand Up @@ -342,4 +345,96 @@ mod tests {
let err = deserr::deserialize::<UnexpectedTuple, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Invalid value at `.me`: the sequence should have exactly 2 elements");
}

#[test]
fn error_did_you_mean() {
#[allow(dead_code)]
#[derive(deserr::Deserr, Debug)]
#[deserr(deny_unknown_fields, rename_all = camelCase)]
struct DidYouMean {
q: Values,
filter: String,
sort: String,
attributes_to_highlight: String,
}

#[derive(deserr::Deserr, Debug)]
#[deserr(rename_all = camelCase)]
enum Values {
Q,
Filter,
Sort,
AttributesToHighLight,
}

let value = json!({ "filler": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `filler`: did you mean `filter`? expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "sart": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `sart`: did you mean `sort`? expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "attributes_to_highlight": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `attributes_to_highlight`: did you mean `attributesToHighlight`? expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "attributesToHighloght": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `attributesToHighloght`: did you mean `attributesToHighlight`? expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

// doesn't match anything

let value = json!({ "a": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `a`: expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "query": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `query`: expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "filterable": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `filterable`: expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

let value = json!({ "sortable": "doggo" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown field `sortable`: expected one of `q`, `filter`, `sort`, `attributesToHighlight`");

// did you mean triggered by an unknown value

let value = json!({ "q": "filler" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `filler` at `.q`: did you mean `filter`? expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "sart" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `sart` at `.q`: did you mean `sort`? expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "attributes_to_highlight" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `attributes_to_highlight` at `.q`: expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "attributesToHighloght" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `attributesToHighloght` at `.q`: did you mean `attributesToHighLight`? expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

// doesn't match anything

let value = json!({ "q": "a" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `a` at `.q`: expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "query" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `query` at `.q`: expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "filterable" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `filterable` at `.q`: expected one of `q`, `filter`, `sort`, `attributesToHighLight`");

let value = json!({ "q": "sortable" });
let err = deserr::deserialize::<DidYouMean, _, JsonError>(value).unwrap_err();
insta::assert_display_snapshot!(err, @"Unknown value `sortable` at `.q`: expected one of `q`, `filter`, `sort`, `attributesToHighLight`");
}
}
23 changes: 23 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ will parse the following:
```
*/
pub use deserr_internal::Deserr;
use strsim::damerau_levenshtein;
pub use value::{IntoValue, Map, Sequence, Value, ValueKind, ValuePointer, ValuePointerRef};

use std::ops::ControlFlow;
Expand Down Expand Up @@ -244,3 +245,25 @@ pub fn take_cf_content<T>(r: ControlFlow<T, T>) -> T {
ControlFlow::Break(x) => x,
}
}

/// Compute a did you mean message.
pub fn did_you_mean(received: &str, accepted: &[&str]) -> String {
let typo_allowed = match received.len() {
// no typos are allowed, we can early return
0..=3 => return String::new(),
4..=7 => 1,
8..=12 => 2,
13..=17 => 3,
18..=24 => 4,
_ => 5,
};
match accepted
.iter()
.map(|accepted| (accepted, damerau_levenshtein(received, accepted)))
.filter(|(_, distance)| distance <= &typo_allowed)
.min_by(|(_, d1), (_, d2)| d1.cmp(d2))
{
None => String::new(),
Some((accepted, _)) => format!("did you mean `{}`? ", accepted),
}
}
Loading

0 comments on commit e5794dd

Please sign in to comment.