Where the sun keeps shinin': the provider pattern in Rust

Where the sun keeps shinin': the provider pattern in Rust

I’m goin’ where the sun keeps shinin’
Through the pourin’ rain
Goin’ where the weather suits my clothes
—Fred Neil, “Everybody’s Talkin’”

The weather is proverbially changeable, and in That mockingbird won’t sing we found that we also need to change the behaviour of our little Rust weather client. When we use it for real, we want it to talk to the Weatherstack API server. But in tests, we’d like it to be able to talk to a local server that provides canned data.

The problem is how to make that change in a user-friendly way. We really don’t want users to have to pass in the server URL, because that makes the code cluttered and fussy:

let weather = get_weather("https://api.weatherstack.com/current",
    "London,UK", "real api key").unwrap(); // wat?

I know, right? The get_weather function has an external dependency—the API server it talks to—and we’d like a more discreet way to inject that dependency into our client.

Functions are great, and they’re the simplest kind of abstraction, but one of their limitations is that it’s not very easy to configure them without taking lots of annoying mandatory arguments.

A provider abstraction

Let’s make a better abstraction, then: a weather provider. We’ll say that a provider is some Rust value that we can create using an API key, and once we have the provider, we can use it to get the weather for various locations.

Because the provider object contains its own configuration, we can turn get_weather into a method, so that users don’t have to pass it a lot of silly paperwork. What we’d like to write is something along these lines:

let ws = Weatherstack::new(&api_key);
let weather = ws.get_weather(&location);

So far, this doesn’t seem like much of an improvement, because users have to call two functions instead of one, for no extra benefit. But the point is that now instead of hard-coding the base URL inside the request function, we can make it configurable:

ws.base_url = server.base_url();

This gives us the best of both worlds. The Weatherstack provider has a default base URL (the real API), so users don’t have to specify it. But in tests we can override that default with the URL of our mock server.

Building the Weatherstack provider

Over to you again to do this refactoring!

GOAL: Make the necessary changes.


HINT: We’ll need a Weatherstack type that can store the user’s API key and the base URL, and we’ll also need a new function that takes the API key as an argument and returns a Weatherstack instance configured with that key.

We’ll also need to change the request function so that, instead of using a hard-coded Weatherstack URL, it honours whatever base_url is set on the Weatherstack instance. Can you see what to do?


SOLUTION: Let’s start with the struct type:

pub struct Weatherstack {
    pub base_url: String,
    api_key: String,
}

impl Weatherstack {

(Listing weather_3)

We needn’t make the api_key field public, as users already have to pass in their API key when they call new. We don’t need to override the field for testing, either, so it can remain private.

Now let’s write new, inside our impl block:

#[must_use]
pub fn new(api_key: &str) -> Self {
    Self {
        base_url: "https://api.weatherstack.com/current".into(),
        api_key: api_key.to_owned(),
    }
}

(Listing weather_3)

My book The Secrets of Rust: Tools is full of little challenges like this: guiding you through the process of building real, useful software in Rust, but letting you get there under your own power. It’s a completely different approach to learning, and you won’t find it in any other Rust book.

When you’ve finished the book, you won’t just have learned a lot about Rust—you’ll have the confidence that comes from knowing you can use it to solve real problems. Now let’s get back to the weather!

Compulsory return values with must_use

Here’s an attribute we haven’t seen before: must_use. It tells Rust that if someone calls this function without using its return value, that should be an error.

In other words, if you write something like:

Weatherstack::new("api key"); // return value is thrown away

the must_use attribute triggers this error:

unused return value of `Weatherstack::new` that must be used

Any time you write a function where it doesn’t make sense to call it if the return value isn’t used, you can add must_use to enforce this (as Clippy will remind you).

get_weather becomes a method

Now comes the point. get_weather moves inside this impl Weatherstack block, and takes &self, as well as location:

pub fn get_weather(&self, location: &str) -> Result<Weather> {
    let resp = request(&self.base_url, location, &self.api_key)
        .send()?;
    let weather = deserialize(&resp.text()?)?;
    Ok(weather)
}

(Listing weather_3)

This makes it a method on the Weatherstack instance, so that it no longer needs to take the API key and base URL. It can get them instead from the self struct.

We turned our delicate noses up earlier at the idea of passing the URL as an argument to the get_weather function, so why is it okay to pass it to request here? Well, users don’t call request; only get_weather does, as an internal implementation detail. It doesn’t really matter how much paperwork we do internally, as long as we spare users from it.

If you like, though, you could refactor request so that it’s also a method on Weatherstack. That way, it doesn’t need to be passed the base URL and API key, because it already has access to them through self, so its arguments are reduced to just location.

Putting Weatherstack to work

That refactoring took a little while, but here comes the payoff. Now we can write the rest of our test against the mock HTTP server:

let mut ws = Weatherstack::new("dummy api key");
ws.base_url = server.base_url() + "/current";
let weather = ws.get_weather("London,UK").unwrap();
assert_eq!(
    weather,
    Weather {
        temperature: 11.2,
        summary: "Sunny".into(),
    },
    "wrong weather"
);

This works, so let’s update our main to use the new abstraction as well:

fn main() -> Result<()> {
    let args = Args::parse();
    let location = args.location.join(" ");
    let ws = Weatherstack::new(&args.api_key);
    let weather = ws.get_weather(&location)?;
    println!("{weather}");
    Ok(())
}

(Listing weather_3)

It’s only one extra line, and we gained a lot of extra flexibility by creating the Weatherstack abstraction.

We could imagine, for example, that maybe people might like to implement other weather providers, so that users could choose whichever data source they want. If we made a Provider trait, anyone could implement it for a particular API by defining a type with a suitable get_weather method.

Bebugging the test

In the meantime, though, it’s already very useful for enabling us to test get_weather against a mock server. Let’s see what happens when we deliberately make that test fail, then, for example by changing the location we ask for:

let weather = ws.get_weather("New York City,USA").unwrap();

This won’t match what the mock server expects, so naturally enough we get a failure:

called `Result::unwrap()` on an `Err` value: bad response:
{"message":"Request did not match any route or mock"}

The “bad response” part tells us this is coming from our deserialize function, which apparently didn’t like the JSON data in the response. No wonder, because it’s not the Weatherstack JSON, it’s a message from the mock server itself:

Request did not match any route or mock

That’s fair, because we asked the mock server to respond only to a very specific request:

when.method(Method::GET)
    .path("/current")
    .query_param("query", "London,UK")
    .query_param("access_key", "dummy api key");

It doesn’t matter if everything else about the request matches perfectly: if the query parameter is wrong, which it is, then the mock server will rightly do nothing but respond with “404 Not Found” status and the message we just saw.

Checking the mock’s assertions

It would be nice, though, if we could get some more information about exactly what didn’t match, and it turns out we can do that too. The server.mock method actually returns something, which we’ve ignored up to now, but let’s instead store it in a variable:

let mock = server.mock(|when, then| {
    ...

What can we do with this mock object now we have it? Well, after making the weather request, we can call its assert method:

mock.assert();

This asks the mock to check that it received the call it was told to expect, and if not, to report the difference:

assertion `left == right` failed: 0 of 1 expected requests matched
the mock specification, .
Here is a comparison with the most similar non-matching request
(request number 1):

1 : Expected query parameter with name 'query' and value
'London,UK' to be present in the request but it wasn't.
------------------------------------------------------------------
Expected:               [key=equals, value=equals] query=London,UK
Actual (closest match):                      query=New York City,USA

  left: "query=London,UK"
 right: "query=New York City,USA"

Pretty comprehensive! Not only does it tell us that it didn’t receive the expected request, it looked at the requests it did receive and guessed that this was intended to be the one. Since it didn’t match, it explains exactly why not, and shows us what it should have been. Very helpful.

Putting it all together

Here’s the complete test, then:

#[test]
fn get_weather_fn_makes_correct_api_call() {
    let server = MockServer::start();
    let mock = server.mock(|when, then| {
        when.method(Method::GET)
            .path("/current")
            .query_param("query", "London,UK")
            .query_param("access_key", "dummy api key");
        then.status(StatusCode::OK.into())
            .header("content-type", "application/json")
            .body_from_file("tests/data/ws.json");
    });
    let mut ws = Weatherstack::new("dummy api key");
    ws.base_url = server.base_url() + "/current";
    let weather = ws.get_weather("London,UK");
    mock.assert();
    assert_eq!(
        weather.unwrap(),
        Weather {
            temperature: 11.2,
            summary: "Sunny".into(),
        },
        "wrong weather"
    );
}

(Listing weather_3)

So you should now be feeling much happier about the correctness of our API-calling code. We’ve tested it from two different angles: first, in Elephants for breakfast, we split up the logic into “format user’s query as HTTP request” and “parse API’s JSON data as weather info” chunks, and tested those independently against fixed expectations.

That kind of microscopic unit testing is all very well, but, as we’ve seen, it doesn’t tell us if we actually glued together the units in the right way. That’s why we also tested them from the second angle: in That mockingbird won’t sing, we used the httpmock crate to create a local server that returns canned data. That way, we can exercise the whole code path of get_weather, including making real HTTP requests and decoding the JSON responses.

When you have great tests, it gives you the confidence to refactor and extend your program without worrying about introducing bugs. We’ve done a lot of work in this series to lay the foundations for a really solid Rust crate; in the next post, let’s reward ourselves a little by adding a fun new feature. Come back real soon!

It's a lock: sync.Mutex in Go

It's a lock: sync.Mutex in Go

0