Effective handover of api responses to your code in dart

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! :grin:

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? :thinking:

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

Diagram illustrating business logic - repository - rest client - API

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 the makeRequest 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 the NetworkResponse result back to the getProducts 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!