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 {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(),
}
}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 awaythe 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)
}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(())
}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"
);
}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!




