Open Meteo API - Functional programming with fpdart (Part 2)

Sandro Maglione

Sandro Maglione

Functional programming

In this series we are going to learn how to convert the Open Meteo API example (from the bloc package) from imperative code to functional programming using fpdart.

We will see step-by-step how to refactor the code to functional programming, as well as the benefits that this brings.

Each post of the series will cover in details one specific aspect of the refactoring. In the first part, Open Meteo API - Functional programming with fpdart, we define the return type using TaskEither, and the possible errors using an abstract class.


In this part 2 we are going to learn how to use fpdart to perform the request:

  • Use the http package to make an API request
  • Validate the request response
  • Extract the data from the response
  • Convert the validated data to a dart class and return it 🚀

Open Meteo API example

This example is part of the flutter_weather app example in the bloc package.

I report here the locationSearch request, which we are refactoring to functional programming in this tutorial:

open_meteo_api_client.dart
/// Finds a [Location] `/v1/search/?name=(query)`.
Future<Location> locationSearch(String query) async {
  final locationRequest = Uri.https(
    _baseUrlGeocoding,
    '/v1/search',
    {'name': query, 'count': '1'},
  );
 
  final locationResponse = await _httpClient.get(locationRequest);
 
  if (locationResponse.statusCode != 200) {
    throw LocationRequestFailure();
  }
 
  final locationJson = jsonDecode(locationResponse.body) as Map;
 
  if (!locationJson.containsKey('results')) throw LocationNotFoundFailure();
 
  final results = locationJson['results'] as List;
 
  if (results.isEmpty) throw LocationNotFoundFailure();
 
  return Location.fromJson(results.first as Map<String, dynamic>);
}

This function does the following:

  • Make an http request (using the http package)
  • Check for a valid response
  • Decode the response from JSON
  • Validate the response format

We are now going to see how to achieve the same result using fpdart.


Make an http request with fpdart

In part 1 we defined the return type as TaskEither:

/// Code implemented and explained in part 1: return [TaskEither] 👇
TaskEither<OpenMeteoApiFpdartLocationFailure, Location> locationSearch(String query)

The first step is the actual http request to Open API. This is the code in the original implementation:

  final locationRequest = Uri.https(
    _baseUrlGeocoding,
    '/v1/search',
    {'name': query, 'count': '1'},
  );
 
  final locationResponse = await _httpClient.get(locationRequest);
  • Define a Uri where to make the request to Open API
  • Send the get request using http

When refactoring this to functional programming we must consider 3 aspects:

  1. Is it possible for this code to fail (throw)?
  2. Does this code contain side effects?
  3. Is this operation synchronous or asynchronous?

Is it possible for this code to fail (throw)?

The first question allows us to define the type used to handle the request:

  • If the request never fails, then no specific type is needed
/// Type [int]: this never fails
int thisNeverFails(String str) => str.length;
  • If the request may fail with a missing value, then we use the Option type
/// Type [Option]: element may be missing
Option<int> getFirst(List<int> list) => list.head;
  • If the request may fail and we want to report an error, then we use the Either type
/// Type [Either]: report an error in case of failure
Either<String, int> divideOrError(int x, int y) {
  if (y == 0) {
    return left('Cannot divide by 0');
  }
  
  return right(x ~/ y);
}

Does this code contain side effects?

Any function that contains side effects requires special care.

In fact, in functional programming we want to handle side effects differently: any function that runs a side effect will be wrapped in a thunk (a function that returns another function).

/// Regular function, no side effect
int noSideEffect(String str) {
  return str.length;
}
 
/// With side effect no special care 💁🏼‍♂️
int withSideEffectImperative(String str) {
  print(str); // Side effect 👈
  return str.length;
}
 
/// With side effect: Use a thunk ⚠️
int Function() withSideEffectFunctional(String str) => () {
      print(str); // Side effect 👈
      return str.length;
    };

The difference between withSideEffectImperative and withSideEffectFunctional is the following:

/// This prints "abc" and gives us the result 🤔
int result1 = withSideEffectImperative("abc");
 
/// This does not do anything 💁🏼‍♂️
int Function() result2 = withSideEffectFunctional("abc");
int realResult2 = result2(); /// You need to explicitly execute it

This strategy allows us to have more control on the effect: no side effect will run until we explicitly execute it.

Using fpdart we do not need to define all these types manually: fpdart already has the types we need to handle side effect, read below 👇

Is this operation synchronous or asynchronous?

The last key question is: synchronous or asynchronous?

This allows us to define the type used to manage any side effect:

  • Synchronous: we use the IO type
/// With side effect: Use a thunk ⚠️
int Function() withSideEffectFunctional(String str) => () {
      print(str); // Side effect (sync) 👈
      return str.length;
    };
 
/// With side effect + `fpdart`: [IO] type
IO<int> withSideEffectIO(String str) => IO(() {
      print(str);
      return str.length;
    });
  • Asynchronous: we use the Task type (as you can see from the example, the main difference is that the function returns a Future)
/// With side effect: Use a thunk ⚠️
Future<int> Function() withSideEffectAsync() => () async {
      final value = await stdin.length;
      return value;
    };
 
/// With side effect + `fpdart`: [Task] type
Task<int> withSideEffectTask() => Task(() async {
      final value = await stdin.length; // Side effect (async) 👈
      return value;
    });

In our example we have the following:

  1. The code may fail for multiple reasons, so we want to use Either to encode the error type
  2. The code makes an http request, which is a side effect
  3. An http request requires async/await, therefore the code is asynchronous (Task)

Given this, we want to use TaskEither.

Note: Even if the return type of the final function is TaskEither, it may be more appropriate to use another fpdart's type for some requests while converting all to TaskEither at the end.

We therefore perform the http request using tryCatch from TaskEither:

TaskEither<OpenMeteoApiFpdartLocationFailure, http.Response>.tryCatch(
  () => _httpClient.get(
    Uri.https(
      _baseUrlGeocoding,
      '/v1/search',
      {'name': query, 'count': '1'},
    ),
  ),
  LocationHttpRequestFpdartFailure.new,
)

You can read more about http request and fpdart in a previous article: How to make API requests with validation in fpdart

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Http response validation using Either

The http request was the only asynchronous operation in this function. Now we want to validate the response and convert it to a Location.

Note: Even if the rest of the code is synchronous, we still need to use TaskEither.

The validation in our example requires 7 steps:

  1. Check that the response is 200 and retrieve the response body
  2. Decode the body from JSON
  3. Cast dynamic (returned by jsonDecode) to a Map
  4. Get the results key the Map (if it exists)
  5. Cast results to a List
  6. Get the first element from the List (if it exists)
  7. Convert the first element of the List to a Location
open_meteo_api_client.dart
Future<Location> locationSearch(String query) async {
  final locationRequest = Uri.https(
    _baseUrlGeocoding,
    '/v1/search',
    {'name': query, 'count': '1'},
  );
 
  final locationResponse = await _httpClient.get(locationRequest);
 
  /// 1. Check valid response
  if (locationResponse.statusCode != 200) {
    throw LocationRequestFailure();
  }
 
  /// 2 & 3. `jsonDecode` and cast to [Map]
  final locationJson = jsonDecode(locationResponse.body) as Map;
 
  /// 4. Get `results` from [Map]
  if (!locationJson.containsKey('results')) throw LocationNotFoundFailure();
 
  /// 5. Cast `results` to [List]
  final results = locationJson['results'] as List;
 
  /// Check if the [List] has at least one element
  if (results.isEmpty) throw LocationNotFoundFailure();
 
  /// 6 & 7. Get first element and convert it to [Location]
  return Location.fromJson(results.first as Map<String, dynamic>);
}

Validation using chainEither from TaskEither

Since all these validations are synchronous, we need to chain a series of Either to the current TaskEither. We are going to use the chainEither method for this.

chainEither gives us the current value from TaskEither and requires us to returns an Either.

Either will contain an error if the validation is unsuccessful (Left), or the valid value otherwise (Right)

The first step is getting the body from the result:

.chainEither(
  (response) => Either<E, http.Response>.fromPredicate(
      response,
      (r) => r.statusCode == 200,
      LocationRequestFpdartFailure.new,
    )
    .map((r) => r.body);
)
  • fromPredicate: Used to check that the statusCode is 200. If it is not, then Either will return a Left containing LocationRequestFpdartFailure
  • map: Once we verified the status code, we extract the body from the response

The second step is using jsonDecode. jsonDecode may fail (throw), so we use the tryCatch method from Either:

.chainEither(
  (body) => Either.tryCatch(
    () => jsonDecode(body),
    (_, __) => LocationInvalidJsonDecodeFpdartFailure(body),
  ),
)

The third step is casting to Map, using the as keyword.

Also casting may fail (throw) in dart. For this reason, fpdart provides a safeCast method that allows to cast a value using Either:

.chainEither(
  (json) => Either<OpenMeteoApiFpdartLocationFailure,
      Map<dynamic, dynamic>>.safeCast(
    json,
    LocationInvalidMapFpdartFailure.new,
  ),
)

Next step is getting results from the Map.

We use the lookup extension method provided by fpdart. lookup returns an Option, which informs us that the value may be missing.

We then convert Option to Either using toEither:

.chainEither(
  (body) => body
      .lookup('results')
      .toEither(LocationKeyNotFoundFpdartFailure.new),
)

We then use safeCast again to convert the value to List:

.chainEither(
  (currentWeather) => Either<OpenMeteoApiFpdartLocationFailure,
      List<dynamic>>.safeCast(
    currentWeather,
    LocationInvalidListFpdartFailure.new,
  ),
)

We extract the first value from the List using head. Just like before, head is an extension method that returns Option, which we then covert to Either:

.chainEither(
  (results) =>
      results.head.toEither(LocationDataNotFoundFpdartFailure.new),
)

Finally, the last step is converting the value to Location.

We use the fromJson method we defined for the Location class. Since this method uses as under the hood, also this may fail.

Therefore, we use again tryCatch to perform the validation:

.chainEither(
  (weather) => Either.tryCatch(
    () => Location.fromJson(weather as Map<String, dynamic>),
    LocationFormattingFpdartFailure.new,
  ),
);

Putting all together: Open Meteo API using fpdart

After all these steps we are finally done! 🎉

The final result is the following:

open_meteo_api_client_fpdart.dart
/// Finds a [Location] `/v1/search/?name=(query)`.
TaskEither<OpenMeteoApiFpdartLocationFailure, Location> locationSearch(String query) =>
    TaskEither<OpenMeteoApiFpdartLocationFailure, http.Response>.tryCatch(
      () => _httpClient.get(
        Uri.https(
          _baseUrlGeocoding,
          '/v1/search',
          {'name': query, 'count': '1'},
        ),
      ),
      LocationHttpRequestFpdartFailure.new,
    )
        .chainEither(
          (response) => Either<E, http.Response>.fromPredicate(
              response,
              (r) => r.statusCode == 200,
              LocationRequestFpdartFailure.new,
            )
            .map((r) => r.body);
        )
        .chainEither(
          (body) => Either.tryCatch(
            () => jsonDecode(body),
            (_, __) => LocationInvalidJsonDecodeFpdartFailure(body),
          ),
        )
        .chainEither(
          (json) => Either<OpenMeteoApiFpdartLocationFailure,
              Map<dynamic, dynamic>>.safeCast(
            json,
            LocationInvalidMapFpdartFailure.new,
          ),
        )
        .chainEither(
          (body) => body
              .lookup('results')
              .toEither(LocationKeyNotFoundFpdartFailure.new),
        )
        .chainEither(
          (currentWeather) => Either<OpenMeteoApiFpdartLocationFailure,
              List<dynamic>>.safeCast(
            currentWeather,
            LocationInvalidListFpdartFailure.new,
          ),
        )
        .chainEither(
          (results) =>
              results.head.toEither(LocationDataNotFoundFpdartFailure.new),
        )
        .chainEither(
          (weather) => Either.tryCatch(
            () => Location.fromJson(weather as Map<String, dynamic>),
            LocationFormattingFpdartFailure.new,
          ),
        );

As you can see, there are a lot of steps involved even for a simple API request.

Using fpdart and functional programming we are able to make this request 100% safe. When calling this function, we are sure that this will never fail and we will receive all the possible errors encoded by the OpenMeteoApiFpdartLocationFailure class.

We can then extract the valid result Location, or map the OpenMeteoApiFpdartLocationFailure to a user-friendly error message, telling exactly what has gone wrong.


That's it for our 2 part series about fpdart and Open Meteo API 🚀

We covered a lot of ground, even for an apparently simple function to make and validate an http request.

Check out the full example on the fpdart repository:

You can subscribe to my newsletter here below to stay always up to date with the latests news, tips, articles, and tutorials about fpart 👇

Thanks for reading.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.