Parse optional values in form-urlencoded

The setup

I'm doing a bit of rust things here and there, and I'm currently looking into building a really simple web form to upload stuff. I hit a problem when I wanted to retrieve an optional value from the html form.

A super basic project will do:

The form is the simplest thing you can imagine:

I want to map that to a struct:

Here's a basic program to convert to and from xxx-url-form-urlencoded.

And when we run it, we get:

url-encoded form: max-size=10
decoded form: Ok(Form { max_size: 12 })

Optional values

Now, I want to have the ability to not specify a maximum size, in this case, a simple solution is to turn the max_size field into an Option<u64>.

The updated main.rs is now:

And it gives us what we want:

url-encoded form: max-size=10
decoded form: Ok(Form { max_size: Some(12) })
decoded none form: Ok(Form { max_size: None })

Dealing with sentinel values

Now, the problem is that the html form needs to give the user a button for that. It's easy enough:

However, when the user selects this new option, the string we get looks like: max-size=None, and if we try to deserialize it like before, we get an error:

So we need a custom deserializer function, which will treat the sentinel value "None" as the rust None value. That is, we want a function like so:

Now, we can leverage serde untagged enums to do the heavy lifting for us. First we define an enum where each variant will be attempted in order:

And then we can use this enum in the deserialize_sentinel function:

Now just have to ask serde to use this function to deserialize our max_size field:

And the main():

gives: decoded form: Ok(Form { max_size: None }). Hooray !

Just to check:

gives decoded form: Err(Error("Unexpected string 10")) /o\ oops.

The bugs

So something is amiss, we can decode None with the sentinel value, but the regular ones can't be deserialize anymore. For non self-describing format like a query string or a url encoded form, the string max-size=12 can be deserialized as String or a number. It seems it's a serde limitation when using serde(flatten) or serde(untagged). Thankfully, there is a workaround.

First, we need to specify a custom deserializing function for the variants of the enum:

And we annotate the relevant variant in Maybe to use this new function:

We also need to add a new trait bound to the function deserialize_sentinel: T: std::str::FromStr otherwise rustc rightly complains that it can't deserialize into a Maybe<T>.

And now, with the following code:

we get:

decoded form: Ok(Form { max_size: None })
decoded form: Ok(Form { max_size: Some(10) })

One last default

With this example and an html form, it won't happen, but if we get an empty string as input? That means the field max-size is missing, and it's legitimate to want to default to None. However, with the current setup, what we get is: decoded form: Err(Error("missing field 'max-size'")).

The fix is simple, even though it took me a while to understand what was going on. We want a default for the struct's field:

And with this last fix we get:

decoded form: Ok(Form { max_size: None }) \o/

Conclusion

That was waaay more tricky than I expected. At least I learned a lot about serde in the process, and this solution seems general enough that I can bring it over to any other project if I need it.

I'm also realising that doing web stuff with html forms in rust is nowhere near as nice as in other language, the ecosystem simply isn't there yet, contrary to what are we web yet pretends. For APIs it's probable better, but handling html forms is a pain.


Thanks to Chouhartem for his proofreading.