Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add try_deserialize deserialization path error tracking #632

Closed

Conversation

allevo
Copy link
Contributor

@allevo allevo commented Jan 9, 2025

Hi!
I'm using config-rs, but I'm having some difficulties finding where my JSON file lacks fields.
I have a nested configuration struct like the one below:

#[derive(Debug, Deserialize)]
struct InnerSettings {
    #[allow(dead_code)]
    value: u32,
    #[allow(dead_code)]
    value2: u32,
}

#[derive(Debug, Deserialize)]
struct Settings {
    #[allow(dead_code)]
    inner: InnerSettings,
}

NB: my use case involves more complex structures

So, if you try to deserialize a wrong JSON, the error will contains the JSON path:

let c = Config::builder()
    .add_source(File::from_str(
        r#"
{
"inner": { "value": 42 }
}
    "#,
        FileFormat::Json,
    ))
    .build()
    .unwrap();

let without_path = c.clone().try_deserialize::<Settings>();
assert_data_eq!(
    without_path.unwrap_err().to_string(),
    str!["inner: missing field `value2`"] // prepended with JSON path
);

This PR:

  • adds serde_path_to_error
  • add a JSON path if not empty in front of the error.
  • adds a test
  • add method documentation

Cargo.toml Outdated Show resolved Hide resolved
Comment on lines 35 to 44
Err(err) => {
let path = err.path().to_string();
assert_eq!(path, "inner.value");

let s = format!("{err}");
assert_eq!(
s,
"inner.value: invalid type: unit value, expected a string for key `inner.value`"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The contributing guide asks for PRs to made so that a test gets added in a commit before the feature work. It should be passing. The feature would would then appropriately update the test. The goal is to be able to show the behavior change that the feature allows.

In most cases, only the assertion changes. So long as this relies on API changes, the initial test commit would then be using the existing APIs and the feature commit would switch it to the new APIs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ops. Sorry, my bad.
Anyway, how do you suggest to proceed?
I can create a first commit a test that uses try_deserialize where the assertion fails (it miss the path). I can put a #[ignore] as well.
After, I can commit the implementation, changing also the test to point to try_deserialize_with_error_path.

Let me know, and sorry again.

Copy link
Contributor

Choose a reason for hiding this comment

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

Please see https://github.com/rust-cli/config-rs/blob/main/CONTRIBUTING.md#pull-requests

We include clap-rs/clap#5520 as an example (I need to find a better one ...)

The test in the first commit should pass and should not be using #[ignore]. The intent behind this is to show how the behavior changed with the feature work and #[ignore] masks that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I write the commit in the right way. Let me know.

@epage
Copy link
Contributor

epage commented Jan 9, 2025

And feel free to squash commits that are just for review feedback

@allevo allevo force-pushed the feat/error-path-on-deserialization branch from 330a815 to 6f535a7 Compare January 9, 2025 16:31
@@ -349,6 +349,6 @@ fn test_json_error_with_path() {
let without_path = c.clone().try_deserialize::<Settings>();
assert_data_eq!(
without_path.unwrap_err().to_string(),
str!["missing field `value2`"]
str!["inner: missing field `value2`"]
Copy link
Contributor

Choose a reason for hiding this comment

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

Now the question is how to format the message.

I feel like this doesn't make clear what inner is.

One idea: "failed reading field inner: missing field value2"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

make sense

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Contributor

Choose a reason for hiding this comment

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

In looking at fn get, I saw that we have some level of path tracking. In those cases, we append the path with for path foo.bar. Should we be consistent one way or the other?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Uhm... probably the current implementation and the implementation of this PR overlap a little bit providing, in some cases, a double information of the error path.

I think we could remove the current implementation of path tracking in favor the one proposed in this PR.
In fact, the current implementation misses some cases. Instead the PR one is more accurate.

I let you decide how to structure the error message (I don't care too much 😅)

Let me know

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tested locally other cases:

#[derive(Debug, Deserialize)]
struct InnerSettings {
    #[allow(dead_code)]
    value: u32,
    #[allow(dead_code)]
    value2: u32,
}

#[derive(Debug, Deserialize)]
struct Settings {
    #[allow(dead_code)]
    inner: InnerSettings,
}

let c = Config::builder()
    .add_source(File::from_str(
        r#"
{
"inner": { "value": 42 }
}
    "#,
        FileFormat::Json5,
    ))
    .build()
    .unwrap();

let with_path = c.clone().try_deserialize::<Settings>();
assert_data_eq!(
    with_path.unwrap_err().to_string(),
    str!["missing field `value2` in path `inner`"]
);

let c = Config::builder()
    .add_source(File::from_str(
        r#"
{
"inner": { "value": "abc" }
}
    "#,
        FileFormat::Json5,
    ))
    .build()
    .unwrap();

let with_path = c.clone().try_deserialize::<Settings>();
assert_data_eq!(
    with_path.unwrap_err().to_string(),
    str!["invalid type: string \"abc\", expected an integer for key `inner.value`"]
);

So:

  • if the key is missing: "missing field value2 in path inner"
  • if the value isn't "castable": "invalid type: string "abc", expected an integer for key inner.value"

it seems to be consistent.

Let me know.

Copy link
Contributor

Choose a reason for hiding this comment

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

That we already have the path for the other error is suspicious and implies we already have path tracking we can take advantage of.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is why I wrote this comment #632 (comment).
Currently, the missing field is not supported because the implementation only track the cast issues and not the deserialization error (which is hard to implement without external library).

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, I misunderstood what you were referring to. We have separate path tracking in get on top of whatever else happens and I thought you were referring to that.

I have a branch with the path tracking without an external library. I'm trying to add origin tracking as well but might give up for now.

Copy link
Contributor

Choose a reason for hiding this comment

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

See #635

@allevo allevo requested a review from epage January 9, 2025 16:41
@allevo allevo force-pushed the feat/error-path-on-deserialization branch from 6f535a7 to 434d6f3 Compare January 9, 2025 16:43
@@ -142,7 +143,20 @@ impl Config {

/// Attempt to deserialize the entire configuration into the requested type.
pub fn try_deserialize<'de, T: Deserialize<'de>>(self) -> Result<T> {
T::deserialize(self)
let mut track = Track::new();
match T::deserialize(Deserializer::new(self, &mut track)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also make this change to fn get?

Copy link
Contributor

Choose a reason for hiding this comment

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

(sorry, just now noticed that this was a whole-document case separate from the path look up case)

@allevo allevo force-pushed the feat/error-path-on-deserialization branch from 434d6f3 to 5eb99ad Compare January 9, 2025 18:18
@coveralls
Copy link

coveralls commented Jan 9, 2025

Pull Request Test Coverage Report for Build 12705981021

Details

  • 9 of 10 (90.0%) changed or added relevant lines in 1 file are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage increased (+0.1%) to 64.927%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/config.rs 9 10 90.0%
Files with Coverage Reduction New Missed Lines %
src/format.rs 1 40.0%
Totals Coverage Status
Change from base Build 12695788504: 0.1%
Covered Lines: 933
Relevant Lines: 1437

💛 - Coveralls

tests/testsuite/errors.rs Outdated Show resolved Hide resolved
@allevo allevo force-pushed the feat/error-path-on-deserialization branch from 5eb99ad to 1fe75f0 Compare January 10, 2025 08:32
@allevo allevo changed the title Add try_deserialize_with_error_path to track where is the deserialization error Add try_deserialize deserialization path error tracking Jan 10, 2025
@@ -341,14 +341,14 @@ fn test_json_error_with_path() {
"inner": { "value": 42 }
}
"#,
FileFormat::Json,
FileFormat::Json5,
Copy link
Contributor

Choose a reason for hiding this comment

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

This commit changes it back to json5. Merge conflict problem?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants