Caveat - If you're not familiar with generics some of the code in this article might be a little confusing to you. I suggest reading up more on dart generics at Dart Academy. No need to worry, I'll wait here till you are more familiar with generics and then keep on explaining when you are ready! 😁
So you're consuming an API
One of the most common requirements in mobile applications is to consume an API. From the basic to some of the more complex applications out there use this to communicate either with a 3rd party service, talk to an application server or interact with server-less infrastructure like Firebase or the like.
When building code that consumes APIs there will always be some good stuff coming back but it could also send some bad stuff your way, in this post I'll take you through how we can effectively pass different types of responses back to our code and always expect the same result.
Enough Talking, let's get into it
Generally when an API call is made there would be a few steps you would need to take before you can use the data returned from the host.
- Call the endpoint
- Wait for the response
- Parse the data or handle any errors
- Use the data in your code or show an error to the user
For most of this article I'll be using a single endpoint, extracted from one of our apps, that uses our own implementation of the dart http client for example purposes.
This endpoint will try and fetch a list of products from our API.
Future<ProductResponse> getProducts(String country) async {
return ProductResponse.fromJson(await _httpClient.get(
endpoint: UrlPaths.PRODUCTS,
endpointHeaders: {'Authorization': token},
queryParameters: {
r'pay_in_country': country,
},
));
}
There can be two types of responses for this endpoint
- A successful response with a list of products
- A error response with a code that defines what went wrong
To fetch the data we could simply call the getProducts
method in our rest client and handle the response. That could look something like this.
try {
final productResponse = await restClient.getProducts(country);
for (var product in productResponse.items) {
print(product.name);
}
} on http.ClientException catch (e) {
showErrorMessage(getErrorFromException(e));
} on TimeoutException catch (e) {
showErrorMessage(getErrorFromException(e));
} on NetworkException catch (e) {
// This is a specific type of exception
// that my client throws when there is an error code
showErrorMessage(getErrorFromException(e));
}
This is fine, we're handling errors and using the data when we receive it successfully but you're probably asking yourself, is there a better way of doing this without all of the try
catch
boilerplate? 🤔
There's a better way
At Mukuru we use something called the Repository Pattern that provides our business logic with easy to use methods to fetch data from our server.
A simple diagram for illustration purposes
Although this diagram is cool it, and using the repository pattern in architectural design, is not the purpose of this article. This pattern just facilitates something that we call the NetworkResponse
.
What is the NetworkResponse
First we have to find out how it works. We can start by calling the getProducts
method again but this time the result will come from our repository in the form of NetworkResponse
.
Let's get our products
NetworkResponse productsResponse = await myRepository.getProducts(myCountryCode);
if (productsResponse.isSuccessful) {
final products = productsRepsonse.result.items;
for (var product in products) {
print(product.name);
}
} else {
showErrorMessage(productsResponse.failure);
}
You can see that the result of our getProducts
call is quite different coming from the repository than it is coming from the rest client in our earlier implementation.
Let's look at what the repository does when we call getProducts
to return the NetworkResponse
.
Future<NetworkResponse<ProductResponse, NetworkException>> getProducts(
String country) async {
return makeRequest(() => restClient.getProducts(country));
}
Looking at the above code you might notice two things.
1. The makeRequest
wrapper
The method makeRequest
wrapping our getProducts
call is a method defined in the base of all our repositories that handles the result from the server.
Here is a simplified implementation of what makeRequest
looks like.
Future<NetworkResponse<T, NetworkException>> makeRequest<T>(Function request) async {
if (await hasInternetConnection()) {
try {
final T result = await request();
return NetworkResponse.success(result);
} on SocketException {
return NetworkResponse.failure(NetworkException(Strings.couldNotReachServer));
} on TimeoutException {
return NetworkResponse.failure(NetworkException(Strings.couldNotReachServer));
} on NetworkException catch (networkException) {
return NetworkResponse.failure(getErrorFromException(networkException));
}
} else {
return NetworkResponse.failure(NetworkException(Strings.noNetworkException));
}
}
All network calls will be executed by makeRequest
by passing the unexecuted future function as an argument and executing it in the the method. The result would then either pass or fail in this function, handled, and would be returned as a NetworkResponse
to the repository allowing us to always return the same object as a result.
The makeRequest
method could also sit in the rest client itself but that's an architectural decision and out of the scope of this post.
2. The two generic arguments that NetworkResponse
consists of
class NetworkResponse<S, F> {
S? _s;
F? _f;
factory NetworkResponse.success(S _s) => NetworkResponse._(_s, null);
factory NetworkResponse.failure(F _f) => NetworkResponse._(null, _f);
NetworkResponse._(this._s, this._f);
bool isSuccessful() => _f == null;
S? result() => _s;
F? failure() => _f;
}
Above is our NetworkResponse
object, here you can see that it consists of two generic types. The first S
is used as a success type and the second F
as the failure type. We then have a few helper methods making it easier for the code using this object to see if the response was successful and to get the result based on that.
Wrap Up
With this new knowledge on NetworkResult
and repositories let's look at this code again with some more detail.
NetworkResponse<ProductResponse, NetworkException> productsResponse = await myRepository.getProducts(myCountryCode);
if (productsResponse.isSuccessful) {
final products = productsRepsonse.result.items;
for (var product in products) {
print(product.name);
}
} else {
showErrorMessage(productsResponse.failure);
}
- We see that calling the
getProducts
method in our repository triggers a call to the rest client via themakeRequest
method. - The rest client will call the API, parse the response and give back the data OR throw an exception on an error code or if something else goes wrong.
- The
makeRequest
method will receive the data or the exception based on what happened in the rest client and hand back theNetworkResponse
result back to thegetProducts
method - The
NetworkResponse
is then given back as a result to our code above.
The beauty of this is that you can always expect the same result from any endpoint and you will almost never need to use a try
catch
in your code calling APIs again.
I hope this was not a waste of your time! Be kind 👇
Header supplied by - www.freepik.com
Top comments (3)
Thank you for the article. If you add the language to the code snippet, it will get nicely colorised and easier to read.
ref: dev.to/hoverbaum/how-to-add-code-h...
Thank you! Didn't know that, followed your suggestion 🔥
Great article and nicely explained dude.. Keep the articles rolling in ;)