All apps need to access some external service. Making an API request is the most common interaction of an app with another service.
An API request looks simple, but in reality you need to pay attention to many details to avoid errors in your app.
fpdart
and functional programming can help! In this post, we are going to learn how to use the full API provided by fpdart
to handle API requests in your app.
Why API requests are troublesome
There are many approaches to structure an app. Each of these involve some sort of layers.
If we reduce this concept to its core, imagine your app divided in 2 layers:
- Internal layer: all the code in your app that works on internal classes and methods (animations, UI, state management)
- External layer: code responsible to access external services (database, API, local storage, analytics)
Everything that deals with the internal structure of our app is relatively safe. The "only" possible source of runtime errors comes from forgetting to handle an Exception
.
In the External layer instead, everything is a mess. Any request may fail for countless reasons (no network, no data, invalid data, missing data, corrupted data). The External layer is responsible for handling all these cases, blocking them from our internal safe layer.
API request case study
In this post we are going to see an common example of API request (this example comes from an issue on the fpdart repository).
import 'dart:convert';
import 'package:fpdart/fpdart.dart';
/// Mock [Response] implementation
class Response {
final String body;
Response(this.body);
}
/// Mock for `post` API request
Response post(
Uri uri, {
Map<String, String>? headers,
}) =>
Response('');
TaskEither<String, String> request() => TaskEither.tryCatch(
() async {
final Response getPrice = await post(
Uri.parse("URL"),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
);
final Map<String, dynamic> json =
jsonDecode(getPrice.body) as Map<String, dynamic>;
if (!json.containsKey("pricing")) {
throw Exception("I don't have price");
}
return json["pricing"].toString();
},
(error, stackTrace) {
return error.toString();
},
);
The code is relatively simple:
- We make an API request using the
post
method - We decode the response from json using
jsonDecode
- We validate that the
pricing
value is present in the response- If the value is not found, we
throw
anException
- Otherwise we return it as a
String
- If the value is not found, we
This logic is wrapped inside TaskEither.tryCatch
from fpdart
, used to handle any possible Exception
in the request.
TaskEither.tryCatch
requires 2 parameters:
- The first parameter is the function to perform the request
- The second parameter is a fallback error returned in case the request fails for any reason
TaskEither
returns anEither
when executed. AnEither
is a type that can contain a valid response or an error.Either
is used to handle errors in functional programming instead of throwingException
s.
In this example, the return type is TaskEither<String, String>
:
- The first
String
is the type of the error (error.toString()
) - The second
String
is the type of the valid response (json["pricing"].toString()
)
fpdart
at its full potential
The above code works. The function is safe, meaning that runtime errors are prevented using TaskEither
.
Nonetheless, it does not take full advantage of the API provided by fpdart
. In fact, it is possible to expand the example to introduce more safety and control. That's what this post is about!
We are going to discuss the following points:
- What is the purpose of
TaskEither.tryCatch
- How to chain methods to
TaskEither
- How to perform validation using
TaskEither
- How to handle errors in
fpdart
What is the purpose of TaskEither.tryCatch
At its core, tryCatch
is an utility function to run a function that may throw and catch any possible Exception. In fact, the implementation of tryCatch
internally is a try / catch
statement:
factory TaskEither.tryCatch(Future<R> Function() run,
L Function(Object error, StackTrace stackTrace) onError) =>
TaskEither<L, R>(() async {
try {
return Right<L, R>(await run());
} catch (error, stack) {
return Left<L, R>(onError(error, stack));
}
});
The purpose of tryCatch
is to run code that can potentially throw. Only this. All other concerns, like other requests, validation, or mapping, should be implemented by chaining other TaskEither
.
The point of
TaskEither
(and functional programming in general) is composability: chaining requests with ease.
Based on this principle, we can refactor the code to include only the API request inside tryCatch
:
TaskEither<String, Response> makeRequest(String url) =>
TaskEither<String, Response>.tryCatch(
() async => post(
Uri.parse(url),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
),
(error, stackTrace) => error.toString(),
);
How to chain methods to TaskEither
If I should not use tryCatch
for mapping the response, how should I do it then?
The answer is chaining, which for TaskEither
means using flatMap
and map
.
In this example, the second step is mapping the response to json. We implement a function that takes a Response
and returns Map<String, dynamic>
:
Map<String, dynamic> mapToJson(Response response) =>
jsonDecode(response.body) as Map<String, dynamic>;
We then use the map
method to change the content of TaskEither
from Response
to Map<String, dynamic>
:
TaskEither<RequestError, Map<String, dynamic>> mappingRequest(String url) =>
makeRequest(url).map(mapToJson);
map
allows to extract the value insideTaskEither
and change its content to another type (in this example,Response
toMap<String, dynamic>
).
How to perform validation using TaskEither
The next step is validation.
We want to ensure that the response contains a pricing
value:
- In case
pricing
is found, return it - Otherwise return an error at the end of the response
Every time we want to chain any kind of code that may fail in some way we use flatMap
.
flatMap
allows to take the value insideTaskEither
and return anotherTaskEither
. If the newTaskEither
fails, then the finalTaskEither
will also fail as well.
flatMap
is similar to map
, but it returns another TaskEither
that is then "flatted" (flat
+ map
). The result looks like this:
TaskEither<String, String> validationRequest(Map<String, dynamic> json) =>
!json.containsKey("pricing")
? TaskEither.left("I don't have price")
: TaskEither.of(json["pricing"].toString());
We can then combine all together to obtain the final request method:
TaskEither<String, String> requestTE(String url) =>
makeRequest(url).map(mapToJson).flatMap(validationRequest);
As you can see, the final method is self-explanatory: make a request (
makeRequest
), map it to json (mapToJson
), and validate it (validationRequest
).
How to handle errors in fpdart
We can improve even further our implementation by making the return types more clear.
First of all, we can use a typedef
to rename the returning String
to a more readable name, like Pricing
:
typedef Pricing = String;
TaskEither<String, Pricing> requestTE(String url) =>
makeRequest(url).map(mapToJson).flatMap(validationRequest);
The second step is making the error more explicit.
The request should not be responsible to convert an error to the corresponding message. Therefore, we can refactor the error from String
to a custom RequestError
class.
For this example, we can define RequestError
as an abstract class that converts the error to a message
:
abstract class RequestError {
String get message;
}
Now we can create different implementations of RequestError
based on the type of error returned by the request:
abstract class RequestError {
String get message;
}
class ApiRequestError implements RequestError {
final Object error;
final StackTrace stackTrace;
ApiRequestError(this.error, this.stackTrace);
@override
String get message => "Error in the API request";
}
class MissingPricingRequestError implements RequestError {
@override
String get message => "Missing pricing in API response";
}
Finally, we replace the error type in TaskEither
from String
to RequestError
:
TaskEither<RequestError, Response> makeRequest(String url) =>
TaskEither<RequestError, Response>.tryCatch(
() async => post(
Uri.parse(url),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
),
(error, stackTrace) => ApiRequestError(error, stackTrace),
);
Map<String, dynamic> mapToJson(Response response) =>
jsonDecode(response.body) as Map<String, dynamic>;
TaskEither<RequestError, Map<String, dynamic>> mappingRequest(String url) =>
makeRequest(url).map(mapToJson);
TaskEither<RequestError, String> validationRequest(Map<String, dynamic> json) =>
!json.containsKey("pricing")
? TaskEither.left(MissingPricingRequestError())
: TaskEither.of(json["pricing"].toString());
TaskEither<RequestError, Pricing> requestTE(String url) =>
makeRequest(url).map(mapToJson).flatMap(validationRequest);
With this, we decouple the request from the code responsible to define the error message.
How to further improve the request
You may be surprised by the amount of code added by this refactoring.
Nonetheless, this is required to handle all possible cases in a simple API request. And not even all of them 🤯.
There are some ways we can improve even further this code. I report them here below without going into more details for now. Feel free to ask for any clarification or examples on these:
- The function is not technically pure. We are accessing the
post
,Uri.parse
, andjsonDecode
methods from a global scope. This methods create implicit dependencies. Furthermore, they make testing more complex to perform, since we cannot swap their implementation. A more solid solution would be to pass these functions as inputs to the request - Using the
as
keyword we are converting adynamic
(returned byjsonDecode
) to aMap<String, dynamic>
. This is not really safe, since it is still possible that the response is not actually aMap
. We would need to validate the correct shape of the response instead of usingas
- Using
typedef
only creates an alias forString
. What if the price returned by the response is invalid (for example< 0
)? Ideally we should further validate the price by wrapping it into aPrice
class. The goal of this class is to ensure that every instance ofPrice
contains indeed a validated price
There is a lot going on even for a simple API request. Do not be scared by this example. Not all these steps are required, the original implementation is still perfectly valid.
This post aims to show you the full extent of the available API of fpdart
.
Feel free to send me a message or comment on my Twitter @SandroMaglione for any clarification or request for further examples.
Thanks for reading.