What would be the "correct" way of wrapping an API with optional parameters?

As rust does not have function overloading, how would you wrap an API endpoint with optional parameters?
Lets say I have an endpoint that takes a file and optional parameter like:

url: /folder/upload
file: file to upload
target: target folder. Leave empty to upload to your home folder.

As far as I can tell there are three possible solutions to this:

  1. Empty target equals no target. Just pass "" as the target.

    pub fn folder_upload (file: file, target: &str) -> Result<Response, Error);

  2. Second Argument is an Option.

    pub fn folder_upload (file: file, target: Option<&str>) -> Result<Response, Error);

  3. Use two functions.

    pub fn folder_upload_target (file: file, target: &str) -> Result<Response, Error);
    pub fn folder_upload (file: file) -> Result<Response, Error);

I usually prefer making it generic over an Into<Option<T>>, that way it's easy to just pass None for an unused parameter and you don't have to pass an explicit Some when it is used.

If there are many parameters, consider wrapping them in a struct of optional fields that implements Default.

5 Likes

One thing to be careful with, if you go down this route - it can lead to situations where type inference fails when you pass None. gtk-rs removed this pattern from their codebase for this reason.

No, that's a different issue.

Type inference fails for None when the inner type of the option is generic, e.g.: (playground)

fn foo<T>(param: Option<T>)

What I'm suggesting instead is a function with a signature like

fn bar<T>(param: T) where T: Into<Option<String>>

If you pass None to bar, it will be inferred to have type Option<String> (playground).

2 Likes

I like it. While it is still not as clean as just leaving out unneeded parameters it gets rid of the ugly Some.
Did not even think about Into. I am still just at the beginning of my rust journey

Using only std traits, it seems we cannot support all of Some("foo"), Some("foo".to_owned()), "foo", "foo".to_owned(), and None.
If you want to accept all of them, you might have to write your own trait...

  • impl Into<Option<String>> argument: &str family is NG
    • OK: None, Some("foo".to_owned()), "foo".to_owned()
    • NG: Some("foo"), "foo"
  • Option<&str> argument: Some is not omissible
    • OK: None, Some("foo"), Some(&"foo".to_owned())
    • NG: "foo", "foo".to_owned()
  • Option<impl AsRef<str>> case (similar to gtk-rs case): None is NG, and Some is not omissible
    • OK: None::<&str>, None::<String>, Some("foo"), Some("foo".to_owned())
    • NG: None, "foo", "foo".to_owned()

You can also use the builder pattern, see this nice blog post for how to do that.
https://www.ameyalokare.com/rust/2017/11/02/rust-builder-pattern.html

2 Likes

To expand on this: given

pub
fn folder_upload<'target> (file: File, target: Option<&'target str>)
  -> Result<Response, Error>
{
    /* body using `file: File` and `target: Option<&'target str>` */
}

you can do:

#[inline]
pub
fn folder_upload<'target> (file: File) // only the mandatory args
  -> FolderUploadBuilder<'target>
{
    FolderUploadBuilder {
        file,
        target: None,
    }
}

#[must_use = "This function is lazy: you need to chain with `.call()`"]
pub
struct FolderUploadBuilder<'target> {
    file: File,
    target: Option<&'target str>, // Optional arg
}

impl<'target> FolderUploadBuilder<'target> {
    pub
    fn call (self)
      -> Result<Response, Error> // Initial return value of our function
    {
        let Self { file, target } = self;
        /* body using `file: File` and `target: Option<&'target str>` */
    }

    #[inline]
    pub
    fn with_target (self, target: &'target str)
      -> Self
    {
        Self {
            target: Some(target),
            .. self
        }
    }
}

So that using this goes as follows:

  • with a specific target

    let file = ...;
    let target = ...;
    let res = folder_upload(file).with(target).call();
    // or
    let res =
        folder_upload(file)
            .with(target) // This line can easily be commented in/out
            .call()
    ;
    
  • no specific target

    let file = ...;
    let res = folder_upload(file).call();
    
  • Playground

2 Likes

A simpler option is to use a macro:

pub
fn folder_upload<'target> (file: File, target: Option<&'target str>)
  -> Result<Response, Error>
{
    /* body using `file: File` and `target: Option<&'target str>` */
}

#[macro_export]
macro_rules! folder_upload {
    (
        $file:expr $(,)?
    ) => (
        $crate::folder_upload($file, None)
    );

    (
        $file:expr,
        $target:expr $(,)?
    ) => (
        $crate::folder_upload($file, Some($target))
    );
}

so that using it is as simple as:

folder_upload!(file);
// or
folder_upload!(file, target);

Thank you everyone. The builder pattern seems really good if I have a lot of optional parameters, so I can avoid the my_function(argument: &str, None, None, None, None) case. I will keep it in mind for the future.
I have not really looked at macros yet, as they seem difficult for a beginner.
Because the maximum number of optional arguments in my case is 2, I will go with the Into<Option<&str>> this time.

1 Like