diff --git a/.gitignore b/.gitignore index 9bddde4c..b1eebb57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .dart_tool/ .packages + +# Generated files +**.g.dart .idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9334d1..144894fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v0.4.0 - Soon +# v0.4.0 - 16 December 2022 - Added extension methods to work with nullable types (`T?`) - From `T?` to `fpdart`'s types - `toOption` @@ -35,7 +35,31 @@ final either = Either.fromNullable(value, (r) => 'none'); final either = Either.fromNullable(value, () => 'none'); ``` -- Added article about [Option type and Null Safety in dart](https://www.sandromaglione.com/techblog/option_type_and_null_safety_dart) +- Added `chainEither` to `TaskEither` +- Added `safeCast` (`Either` and `Option`) +- Added `safeCastStrict` (`Either` and `Option`) +```dart +int intValue = 10; + +/// Unhandled exception: type 'int' is not a subtype of type 'List' in type cast +final waitWhat = intValue as List; +final first = waitWhat.first; + +/// Safe 🎯 +final wellYeah = Either>.safeCast( + intValue, + (dynamic value) => 'Not a List!', +); +final firstEither = wellYeah.map((list) => list.first); +``` +- Added [**Open API Meteo example**](./example/open_meteo_api/) (from imperative to functional programming) +- Added new articles + - [Option type and Null Safety in dart](https://www.sandromaglione.com/techblog/option_type_and_null_safety_dart) + - [Either - Error Handling in Functional Programming](https://www.sandromaglione.com/techblog/either-error-handling-functional-programming) + - [Future & Task: asynchronous Functional Programming](https://www.sandromaglione.com/techblog/async-requests-future-and-task-dart) + - [Flutter Supabase Functional Programming with fpdart](https://www.sandromaglione.com/techblog/flutter-dart-functional-programming-fpdart-supabase-app) + - [Open Meteo API - Functional programming with fpdart (Part 1)](https://www.sandromaglione.com/techblog/real_example_fpdart_open_meteo_api_part_1) + - [Open Meteo API - Functional programming with fpdart (Part 2)](https://www.sandromaglione.com/techblog/real_example_fpdart_open_meteo_api_part_2) # v0.3.0 - 11 October 2022 - Inverted `onSome` and `onNone` functions parameters in `match` method of `Option` [⚠️ **BREAKING CHANGE**] (*Read more on why* 👉 [#56](https://github.com/SandroMaglione/fpdart/pull/56)) diff --git a/README.md b/README.md index f66b9124..760a3a89 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,38 @@ Check out also this series of articles about functional programming with `fpdart - [How to use TaskEither in fpdart](https://www.sandromaglione.com/techblog/how-to-use-task-either-fpdart-functional-programming) - [How to map an Either to a Future in fpdart](https://blog.sandromaglione.com/techblog/from-sync-to-async-functional-programming) - [Option type and Null Safety in dart](https://www.sandromaglione.com/techblog/option_type_and_null_safety_dart) +- [Either - Error Handling in Functional Programming](https://www.sandromaglione.com/techblog/either-error-handling-functional-programming) +- [Future & Task: asynchronous Functional Programming](https://www.sandromaglione.com/techblog/async-requests-future-and-task-dart) +- [Flutter Supabase Functional Programming with fpdart](https://www.sandromaglione.com/techblog/flutter-dart-functional-programming-fpdart-supabase-app) + ## 💻 Installation ```yaml # pubspec.yaml dependencies: - fpdart: ^0.3.0 # Check out the latest version + fpdart: ^0.4.0 # Check out the latest version ``` ## ✨ Examples +### [Pokeapi](./example/pokeapi_functional/) +Flutter app that lets you search and view your favorite Pokemon: +- API request +- Response validation +- JSON conversion +- State management ([riverpod](https://pub.dev/packages/riverpod)) + +### [Open Meteo API](./example/open_meteo_api/) +Re-implementation using `fpdart` and functional programming of the [Open Meteo API](https://github.com/felangel/bloc/tree/master/examples/flutter_weather/packages/open_meteo_api) from the [flutter_weather](https://bloclibrary.dev/#/flutterweathertutorial) app example in the [bloc](https://pub.dev/packages/bloc) package. + +A 2 parts series explains step by step the Open Meteo API code: +- [Open Meteo API - Functional programming with fpdart (Part 1)](https://www.sandromaglione.com/techblog/real_example_fpdart_open_meteo_api_part_1) +- [Open Meteo API - Functional programming with fpdart (Part 2)](https://www.sandromaglione.com/techblog/real_example_fpdart_open_meteo_api_part_2) + +### [Read/Write local file](./example/read_write_file/) +Example of how to read and write a local file using functional programming. + ### [Option](./lib/src/option.dart) Used when a return value can be missing. > For example, when parsing a `String` to `int`, since not all `String` @@ -315,6 +336,7 @@ In general, **any contribution or feedback is welcome** (and encouraged!). ## 📃 Versioning +- **v0.4.0** - 16 December 2022 - **v0.3.0** - 11 October 2022 - **v0.2.0** - 16 July 2022 - **v0.1.0** - 17 June 2022 diff --git a/example/json_serializable/pubspec.lock b/example/json_serializable/pubspec.lock index f07cb66a..fe9c83c0 100644 --- a/example/json_serializable/pubspec.lock +++ b/example/json_serializable/pubspec.lock @@ -175,7 +175,7 @@ packages: path: "../.." relative: true source: path - version: "0.3.0" + version: "0.3.1" frontend_server_client: dependency: transitive description: diff --git a/example/open_meteo_api/README.md b/example/open_meteo_api/README.md new file mode 100644 index 00000000..18412255 --- /dev/null +++ b/example/open_meteo_api/README.md @@ -0,0 +1,26 @@ +# Open Meteo API - `fpdart` +This is a re-implementation using `fpdart` and functional programming of the [Open Meteo API](https://github.com/felangel/bloc/tree/master/examples/flutter_weather/packages/open_meteo_api) from the [flutter_weather](https://bloclibrary.dev/#/flutterweathertutorial) app example in the [bloc](https://pub.dev/packages/bloc) package. + +The goal is to show a comparison between usual dart code and functional code written using `fpdart`. + +## Structure +The example is simple but comprehensive. + +The Open Meteo API implementation is only 1 file. The original source is [open_meteo_api_client.dart](./lib/src/open_meteo_api_client.dart) (copy of the [bloc package implementation](https://github.com/felangel/bloc/blob/master/examples/flutter_weather/packages/open_meteo_api/lib/src/open_meteo_api_client.dart)). + +Inside [lib/src/fpdart](./lib/src/fpdart/) you can then find the refactoring using functional programming and `fpdart`: +- [open_meteo_api_client_fpdart.dart](./lib/src/fpdart/open_meteo_api_client_fpdart.dart): implementation of the Open Meteo API with `fpdart` +- [location_failure.dart](./lib/src/fpdart/location_failure.dart): failure classes for the `locationSearch` request +- [weather_failure.dart](./lib/src/fpdart/weather_failure.dart): failure classes for the `getWeather` request + +### Test +Also the [test](./test/) has been rewritten based on the `fpdart` refactoring: +- [open_meteo_api_client_test.dart](./test/open_meteo_api_client_test.dart): Original Open Meteo API test implementation +- [open_meteo_api_client_test_fpdart.dart](./test/open_meteo_api_client_test_fpdart.dart): Testing for the new implementation using `fpdart` and functional programming + +## Types used from `fpdart` +- `TaskEither`: Used instead of `Future` to make async request that may fail +- `Either`: Used to validate the response from the API with either an error or a valid value +- `Option`: Used to get values that may be missing + - `lookup` in a `Map`: getting a value by key in a `Map` may return nothing if the key is not found + - `head` in a `List`: The list may be empty, so getting the first element (called _"head"_) may return nothing \ No newline at end of file diff --git a/example/open_meteo_api/analysis_options.yaml b/example/open_meteo_api/analysis_options.yaml new file mode 100644 index 00000000..1d5ab95c --- /dev/null +++ b/example/open_meteo_api/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:very_good_analysis/analysis_options.3.0.2.yaml +analyzer: + exclude: + - lib/**/*.g.dart + +linter: + rules: + public_member_api_docs: false diff --git a/example/open_meteo_api/build.yaml b/example/open_meteo_api/build.yaml new file mode 100644 index 00000000..917f4561 --- /dev/null +++ b/example/open_meteo_api/build.yaml @@ -0,0 +1,12 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter + json_serializable: + options: + field_rename: snake + create_to_json: false + checked: true diff --git a/example/open_meteo_api/lib/open_meteo_api.dart b/example/open_meteo_api/lib/open_meteo_api.dart new file mode 100644 index 00000000..90cc854d --- /dev/null +++ b/example/open_meteo_api/lib/open_meteo_api.dart @@ -0,0 +1,5 @@ +library open_meteo_api; + +export 'src/fpdart/open_meteo_api_client_fpdart.dart'; +export 'src/models/models.dart'; +export 'src/open_meteo_api_client.dart'; diff --git a/example/open_meteo_api/lib/src/fpdart/location_failure.dart b/example/open_meteo_api/lib/src/fpdart/location_failure.dart new file mode 100644 index 00000000..b428044b --- /dev/null +++ b/example/open_meteo_api/lib/src/fpdart/location_failure.dart @@ -0,0 +1,61 @@ +import 'package:http/http.dart' as http; + +/// Abstract class which represents a failure in the `locationSearch` request. +abstract class OpenMeteoApiFpdartLocationFailure {} + +/// [OpenMeteoApiFpdartLocationFailure] when **http request** fails +class LocationHttpRequestFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationHttpRequestFpdartFailure(this.object, this.stackTrace); + final Object object; + final StackTrace stackTrace; +} + +/// [OpenMeteoApiFpdartLocationFailure] when request is not successful +/// (`status != 200`) +class LocationRequestFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationRequestFpdartFailure(this.response); + final http.Response response; +} + +/// [OpenMeteoApiFpdartLocationFailure] when location response +/// cannot be decoded from json. +class LocationInvalidJsonDecodeFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationInvalidJsonDecodeFpdartFailure(this.body); + final String body; +} + +/// [OpenMeteoApiFpdartLocationFailure] when location response is not a valid [Map]. +class LocationInvalidMapFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationInvalidMapFpdartFailure(this.json); + final dynamic json; +} + +/// [OpenMeteoApiFpdartLocationFailure] when location information +/// is not found (missing expected key). +class LocationKeyNotFoundFpdartFailure + implements OpenMeteoApiFpdartLocationFailure {} + +/// [OpenMeteoApiFpdartLocationFailure] when location data is not a valid [List]. +class LocationInvalidListFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationInvalidListFpdartFailure(this.value); + final dynamic value; +} + +/// [OpenMeteoApiFpdartLocationFailure] when location for provided location +/// is not found (missing data). +class LocationDataNotFoundFpdartFailure + implements OpenMeteoApiFpdartLocationFailure {} + +/// [OpenMeteoApiFpdartLocationFailure] when the response is not +/// a valid [Location] +class LocationFormattingFpdartFailure + implements OpenMeteoApiFpdartLocationFailure { + const LocationFormattingFpdartFailure(this.object, this.stackTrace); + final Object object; + final StackTrace stackTrace; +} diff --git a/example/open_meteo_api/lib/src/fpdart/open_meteo_api_client_fpdart.dart b/example/open_meteo_api/lib/src/fpdart/open_meteo_api_client_fpdart.dart new file mode 100644 index 00000000..161ae0df --- /dev/null +++ b/example/open_meteo_api/lib/src/fpdart/open_meteo_api_client_fpdart.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:fpdart/fpdart.dart'; +import 'package:http/http.dart' as http; +import 'package:open_meteo_api/open_meteo_api.dart'; +import 'package:open_meteo_api/src/fpdart/location_failure.dart'; +import 'package:open_meteo_api/src/fpdart/weather_failure.dart'; + +class OpenMeteoApiClientFpdart { + OpenMeteoApiClientFpdart({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + static const _baseUrlWeather = 'api.open-meteo.com'; + static const _baseUrlGeocoding = 'geocoding-api.open-meteo.com'; + + final http.Client _httpClient; + + /// Finds a [Location] `/v1/search/?name=(query)`. + TaskEither locationSearch( + String query) => + TaskEither.tryCatch( + () => _httpClient.get( + Uri.https( + _baseUrlGeocoding, + '/v1/search', + {'name': query, 'count': '1'}, + ), + ), + LocationHttpRequestFpdartFailure.new, + ) + .chainEither( + (response) => + _validResponseBody(response, LocationRequestFpdartFailure.new), + ) + .chainEither( + (body) => Either.tryCatch( + () => jsonDecode(body), + (_, __) => LocationInvalidJsonDecodeFpdartFailure(body), + ), + ) + .chainEither( + (json) => Either>.safeCast( + json, + LocationInvalidMapFpdartFailure.new, + ), + ) + .chainEither( + (body) => body + .lookup('results') + .toEither(LocationKeyNotFoundFpdartFailure.new), + ) + .chainEither( + (currentWeather) => Either>.safeCast( + currentWeather, + LocationInvalidListFpdartFailure.new, + ), + ) + .chainEither( + (results) => + results.head.toEither(LocationDataNotFoundFpdartFailure.new), + ) + .chainEither( + (weather) => Either.tryCatch( + () => Location.fromJson(weather as Map), + LocationFormattingFpdartFailure.new, + ), + ); + + /// Fetches [Weather] for a given [latitude] and [longitude]. + TaskEither getWeather({ + required double latitude, + required double longitude, + }) => + TaskEither.tryCatch( + () async => _httpClient.get( + Uri.https( + _baseUrlWeather, + 'v1/forecast', + { + 'latitude': '$latitude', + 'longitude': '$longitude', + 'current_weather': 'true' + }, + ), + ), + WeatherHttpRequestFpdartFailure.new, + ) + .chainEither( + (response) => + _validResponseBody(response, WeatherRequestFpdartFailure.new), + ) + .chainEither( + (body) => Either.safeCastStrict< + OpenMeteoApiFpdartWeatherFailure, + Map, + String>(body, WeatherInvalidMapFpdartFailure.new), + ) + .chainEither( + (body) => body + .lookup('current_weather') + .toEither(WeatherKeyNotFoundFpdartFailure.new), + ) + .chainEither( + (currentWeather) => Either>.safeCast( + currentWeather, + WeatherInvalidListFpdartFailure.new, + ), + ) + .chainEither( + (results) => + results.head.toEither(WeatherDataNotFoundFpdartFailure.new), + ) + .chainEither( + (weather) => Either.tryCatch( + () => Weather.fromJson(weather as Map), + WeatherFormattingFpdartFailure.new, + ), + ); + + /// Verify that the response status code is 200, + /// and extract the response's body. + Either _validResponseBody( + http.Response response, + E Function(http.Response) onError, + ) => + Either.fromPredicate( + response, + (r) => r.statusCode == 200, + onError, + ).map((r) => r.body); +} diff --git a/example/open_meteo_api/lib/src/fpdart/weather_failure.dart b/example/open_meteo_api/lib/src/fpdart/weather_failure.dart new file mode 100644 index 00000000..a6cbbcd4 --- /dev/null +++ b/example/open_meteo_api/lib/src/fpdart/weather_failure.dart @@ -0,0 +1,50 @@ +import 'package:http/http.dart' as http; + +/// Abstract class which represents a failure in the `getWeather` request. +abstract class OpenMeteoApiFpdartWeatherFailure {} + +/// [OpenMeteoApiFpdartWeatherFailure] when **http request** fails +class WeatherHttpRequestFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure { + const WeatherHttpRequestFpdartFailure(this.object, this.stackTrace); + final Object object; + final StackTrace stackTrace; +} + +/// [OpenMeteoApiFpdartWeatherFailure] when getWeather fails +class WeatherRequestFpdartFailure implements OpenMeteoApiFpdartWeatherFailure { + const WeatherRequestFpdartFailure(this.response); + final http.Response response; +} + +/// [OpenMeteoApiFpdartWeatherFailure] when weather response is not a valid [Map]. +class WeatherInvalidMapFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure { + const WeatherInvalidMapFpdartFailure(this.body); + final String body; +} + +/// [OpenMeteoApiFpdartWeatherFailure] when weather information +/// is not found (missing expected key). +class WeatherKeyNotFoundFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure {} + +/// [OpenMeteoApiFpdartWeatherFailure] when weather data is not a valid [List]. +class WeatherInvalidListFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure { + const WeatherInvalidListFpdartFailure(this.value); + final dynamic value; +} + +/// [OpenMeteoApiFpdartWeatherFailure] when weather for provided location +/// is not found (missing data). +class WeatherDataNotFoundFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure {} + +/// [OpenMeteoApiFpdartWeatherFailure] when the response is not a valid [Weather] +class WeatherFormattingFpdartFailure + implements OpenMeteoApiFpdartWeatherFailure { + const WeatherFormattingFpdartFailure(this.object, this.stackTrace); + final Object object; + final StackTrace stackTrace; +} diff --git a/example/open_meteo_api/lib/src/models/location.dart b/example/open_meteo_api/lib/src/models/location.dart new file mode 100644 index 00000000..f11c46c0 --- /dev/null +++ b/example/open_meteo_api/lib/src/models/location.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'location.g.dart'; + +@JsonSerializable() +class Location { + const Location({ + required this.id, + required this.name, + required this.latitude, + required this.longitude, + }); + + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + final int id; + final String name; + final double latitude; + final double longitude; +} diff --git a/example/open_meteo_api/lib/src/models/models.dart b/example/open_meteo_api/lib/src/models/models.dart new file mode 100644 index 00000000..4f0d8637 --- /dev/null +++ b/example/open_meteo_api/lib/src/models/models.dart @@ -0,0 +1,2 @@ +export 'location.dart'; +export 'weather.dart'; diff --git a/example/open_meteo_api/lib/src/models/weather.dart b/example/open_meteo_api/lib/src/models/weather.dart new file mode 100644 index 00000000..bb2fc687 --- /dev/null +++ b/example/open_meteo_api/lib/src/models/weather.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'weather.g.dart'; + +@JsonSerializable() +class Weather { + const Weather({required this.temperature, required this.weatherCode}); + + factory Weather.fromJson(Map json) => + _$WeatherFromJson(json); + + final double temperature; + @JsonKey(name: 'weathercode') + final double weatherCode; +} diff --git a/example/open_meteo_api/lib/src/open_meteo_api_client.dart b/example/open_meteo_api/lib/src/open_meteo_api_client.dart new file mode 100644 index 00000000..103eb517 --- /dev/null +++ b/example/open_meteo_api/lib/src/open_meteo_api_client.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:open_meteo_api/open_meteo_api.dart'; + +/// Exception thrown when locationSearch fails. +class LocationRequestFailure implements Exception {} + +/// Exception thrown when the provided location is not found. +class LocationNotFoundFailure implements Exception {} + +/// Exception thrown when getWeather fails. +class WeatherRequestFailure implements Exception {} + +/// Exception thrown when weather for provided location is not found. +class WeatherNotFoundFailure implements Exception {} + +/// {@template open_meteo_api_client} +/// Dart API Client which wraps the [Open Meteo API](https://open-meteo.com). +/// {@endtemplate} +class OpenMeteoApiClient { + /// {@macro open_meteo_api_client} + OpenMeteoApiClient({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + static const _baseUrlWeather = 'api.open-meteo.com'; + static const _baseUrlGeocoding = 'geocoding-api.open-meteo.com'; + + final http.Client _httpClient; + + /// Finds a [Location] `/v1/search/?name=(query)`. + Future 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); + } + + /// Fetches [Weather] for a given [latitude] and [longitude]. + Future getWeather({ + required double latitude, + required double longitude, + }) async { + final weatherRequest = Uri.https(_baseUrlWeather, 'v1/forecast', { + 'latitude': '$latitude', + 'longitude': '$longitude', + 'current_weather': 'true' + }); + + final weatherResponse = await _httpClient.get(weatherRequest); + + if (weatherResponse.statusCode != 200) { + throw WeatherRequestFailure(); + } + + final bodyJson = jsonDecode(weatherResponse.body) as Map; + + if (!bodyJson.containsKey('current_weather')) { + throw WeatherNotFoundFailure(); + } + + final weatherJson = bodyJson['current_weather'] as Map; + + return Weather.fromJson(weatherJson); + } +} diff --git a/example/open_meteo_api/pubspec.lock b/example/open_meteo_api/pubspec.lock new file mode 100644 index 00000000..51316fbe --- /dev/null +++ b/example/open_meteo_api/pubspec.lock @@ -0,0 +1,488 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "49.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.4" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.4" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + fpdart: + dependency: "direct main" + description: + path: "../.." + relative: true + source: path + version: "0.4.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.5" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "6.5.1" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + mocktail: + dependency: "direct dev" + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.6" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.3" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.6" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.14" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.18" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "9.4.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.18.0 <3.0.0" diff --git a/example/open_meteo_api/pubspec.yaml b/example/open_meteo_api/pubspec.yaml new file mode 100644 index 00000000..a8f88e64 --- /dev/null +++ b/example/open_meteo_api/pubspec.yaml @@ -0,0 +1,21 @@ +name: open_meteo_api +description: A Dart API Client for the Open-Meteo API. +version: 1.0.0+1 +publish_to: "none" + +environment: + sdk: ">=2.18.0 <3.0.0" + +dependencies: + fpdart: + path: ../../. + + http: ^0.13.0 + json_annotation: ^4.6.0 + +dev_dependencies: + build_runner: ^2.0.0 + json_serializable: ^6.3.1 + mocktail: ^0.3.0 + test: ^1.16.4 + very_good_analysis: ^3.0.2 diff --git a/example/open_meteo_api/test/open_meteo_api_client_test.dart b/example/open_meteo_api/test/open_meteo_api_client_test.dart new file mode 100644 index 00000000..60a36aa6 --- /dev/null +++ b/example/open_meteo_api/test/open_meteo_api_client_test.dart @@ -0,0 +1,202 @@ +// ignore_for_file: prefer_const_constructors +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:open_meteo_api/open_meteo_api.dart'; +import 'package:test/test.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +class MockResponse extends Mock implements http.Response {} + +class FakeUri extends Fake implements Uri {} + +void main() { + group('OpenMeteoApiClient', () { + late http.Client httpClient; + late OpenMeteoApiClient apiClient; + + setUpAll(() { + registerFallbackValue(FakeUri()); + }); + + setUp(() { + httpClient = MockHttpClient(); + apiClient = OpenMeteoApiClient(httpClient: httpClient); + }); + + group('constructor', () { + test('does not require an httpClient', () { + expect(OpenMeteoApiClient(), isNotNull); + }); + }); + + group('locationSearch', () { + const query = 'mock-query'; + test('makes correct http request', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + try { + await apiClient.locationSearch(query); + } catch (_) {} + verify( + () => httpClient.get( + Uri.https( + 'geocoding-api.open-meteo.com', + '/v1/search', + {'name': query, 'count': '1'}, + ), + ), + ).called(1); + }); + + test('throws LocationRequestFailure on non-200 response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(400); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + expect( + () async => apiClient.locationSearch(query), + throwsA(isA()), + ); + }); + + test('throws LocationNotFoundFailure on error response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + await expectLater( + apiClient.locationSearch(query), + throwsA(isA()), + ); + }); + + test('throws LocationNotFoundFailure on empty response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{"results": []}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + await expectLater( + apiClient.locationSearch(query), + throwsA(isA()), + ); + }); + + test('returns Location on valid response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn( + ''' +{ + "results": [ + { + "id": 4887398, + "name": "Chicago", + "latitude": 41.85003, + "longitude": -87.65005 + } + ] +}''', + ); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + final actual = await apiClient.locationSearch(query); + expect( + actual, + isA() + .having((l) => l.name, 'name', 'Chicago') + .having((l) => l.id, 'id', 4887398) + .having((l) => l.latitude, 'latitude', 41.85003) + .having((l) => l.longitude, 'longitude', -87.65005), + ); + }); + }); + + group('getWeather', () { + const latitude = 41.85003; + const longitude = -87.6500; + + test('makes correct http request', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + try { + await apiClient.getWeather(latitude: latitude, longitude: longitude); + } catch (_) {} + verify( + () => httpClient.get( + Uri.https('api.open-meteo.com', 'v1/forecast', { + 'latitude': '$latitude', + 'longitude': '$longitude', + 'current_weather': 'true' + }), + ), + ).called(1); + }); + + test('throws WeatherRequestFailure on non-200 response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(400); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + expect( + () async => apiClient.getWeather( + latitude: latitude, + longitude: longitude, + ), + throwsA(isA()), + ); + }); + + test('throws WeatherNotFoundFailure on empty response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + expect( + () async => apiClient.getWeather( + latitude: latitude, + longitude: longitude, + ), + throwsA(isA()), + ); + }); + + test('returns weather on valid response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn( + ''' +{ +"latitude": 43, +"longitude": -87.875, +"generationtime_ms": 0.2510547637939453, +"utc_offset_seconds": 0, +"timezone": "GMT", +"timezone_abbreviation": "GMT", +"elevation": 189, +"current_weather": { +"temperature": 15.3, +"windspeed": 25.8, +"winddirection": 310, +"weathercode": 63, +"time": "2022-09-12T01:00" +} +} + ''', + ); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + final actual = await apiClient.getWeather( + latitude: latitude, + longitude: longitude, + ); + expect( + actual, + isA() + .having((w) => w.temperature, 'temperature', 15.3) + .having((w) => w.weatherCode, 'weatherCode', 63.0), + ); + }); + }); + }); +} diff --git a/example/open_meteo_api/test/open_meteo_api_client_test_fpdart.dart b/example/open_meteo_api/test/open_meteo_api_client_test_fpdart.dart new file mode 100644 index 00000000..207867bd --- /dev/null +++ b/example/open_meteo_api/test/open_meteo_api_client_test_fpdart.dart @@ -0,0 +1,249 @@ +// ignore_for_file: prefer_const_constructors +import 'package:fpdart/fpdart.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:open_meteo_api/open_meteo_api.dart'; +import 'package:open_meteo_api/src/fpdart/location_failure.dart'; +import 'package:test/test.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +class MockResponse extends Mock implements http.Response {} + +class FakeUri extends Fake implements Uri {} + +void _isLeftOfType( + Either result, { + dynamic Function(TypeMatcher)? typeMatch, +}) { + expect(result, isA>()); + result.match( + (l) => expect(l, typeMatch != null ? typeMatch(isA()) : isA()), + (_) => fail('should not be right'), + ); +} + +void main() { + group('OpenMeteoApiClientFpdart', () { + late http.Client httpClient; + late OpenMeteoApiClientFpdart apiClient; + + setUpAll(() { + registerFallbackValue(FakeUri()); + }); + + setUp(() { + httpClient = MockHttpClient(); + apiClient = OpenMeteoApiClientFpdart(httpClient: httpClient); + }); + + group('constructor', () { + test('does not require an httpClient', () { + expect(OpenMeteoApiClientFpdart(), isNotNull); + }); + }); + + group('locationSearch', () { + const query = 'mock-query'; + test('makes correct http request', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + /// No need of try/catch + await apiClient.locationSearch(query).run(); + + verify( + () => httpClient.get( + Uri.https( + 'geocoding-api.open-meteo.com', + '/v1/search', + {'name': query, 'count': '1'}, + ), + ), + ).called(1); + }); + + test('returns LocationHttpRequestFpdartFailure when http request fails', + () async { + when(() => httpClient.get(any())).thenThrow(Exception()); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType( + result, + typeMatch: (m) => m.having( + (failure) => failure.object, + 'Exception', + isA(), + ), + ); + }); + + test('returns LocationRequestFpdartFailure on non-200 response', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(400); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType( + result, + typeMatch: (m) => m.having( + (failure) => failure.response, + 'MockResponse', + response, + ), + ); + }); + + test( + 'returns LocationInvalidJsonDecodeFpdartFailure when response is invalid', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('_{}_'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType( + result, + typeMatch: (m) => m.having( + (failure) => failure.body, + 'body', + '_{}_', + ), + ); + }); + + test('returns LocationInvalidMapFpdartFailure when response is not a Map', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('[]'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType( + result, + typeMatch: (m) => m.having( + (failure) => failure.json, + 'json', + [], + ), + ); + }); + + test( + 'returns LocationKeyNotFoundFpdartFailure when the response is missing the correct key', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType(result); + }); + + test( + 'returns LocationInvalidListFpdartFailure when Map key does not contain a valid List', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{"results": {}}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType( + result, + typeMatch: (m) => m.having( + (failure) => failure.value, + 'value', + {}, + ), + ); + }); + + test('returns LocationDataNotFoundFpdartFailure on empty response', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn('{"results": []}'); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType(result); + }); + + test( + 'returns LocationFormattingFpdartFailure when response is not a correct Location object', + () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn( + ''' +{ + "results": [ + { + "_id": 4887398, + "_name": "Chicago", + "_latitude": 41.85003, + "_longitude": -87.65005 + } + ] +}''', + ); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + + _isLeftOfType(result); + }); + + test('returns Location on valid response', () async { + final response = MockResponse(); + when(() => response.statusCode).thenReturn(200); + when(() => response.body).thenReturn( + ''' +{ + "results": [ + { + "id": 4887398, + "name": "Chicago", + "latitude": 41.85003, + "longitude": -87.65005 + } + ] +}''', + ); + when(() => httpClient.get(any())).thenAnswer((_) async => response); + + final result = await apiClient.locationSearch(query).run(); + expect( + result, + isA>() + .having((l) => l.value.name, 'name', 'Chicago') + .having((l) => l.value.id, 'id', 4887398) + .having((l) => l.value.latitude, 'latitude', 41.85003) + .having((l) => l.value.longitude, 'longitude', -87.65005), + ); + }); + }); + }); +} diff --git a/example/pokeapi_functional/pubspec.lock b/example/pokeapi_functional/pubspec.lock index 7392d411..6a2e2699 100644 --- a/example/pokeapi_functional/pubspec.lock +++ b/example/pokeapi_functional/pubspec.lock @@ -28,7 +28,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.8.2" + version: "2.9.0" boolean_selector: dependency: transitive description: @@ -98,7 +98,7 @@ packages: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.1" charcode: dependency: transitive description: @@ -126,7 +126,7 @@ packages: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" code_builder: dependency: transitive description: @@ -168,7 +168,7 @@ packages: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.3.1" file: dependency: transitive description: @@ -213,7 +213,7 @@ packages: path: "../.." relative: true source: path - version: "0.2.1" + version: "0.3.1" freezed: dependency: "direct main" description: @@ -311,21 +311,21 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.11" + version: "0.12.12" material_color_utilities: dependency: transitive description: name: material_color_utilities url: "https://pub.dartlang.org" source: hosted - version: "0.1.4" + version: "0.1.5" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.7.0" + version: "1.8.0" mime: dependency: transitive description: @@ -346,7 +346,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.2" pedantic: dependency: transitive description: @@ -414,7 +414,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.2" + version: "1.9.0" stack_trace: dependency: transitive description: @@ -449,21 +449,21 @@ packages: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.1" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.9" + version: "0.4.12" timing: dependency: transitive description: @@ -507,5 +507,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.17.0-0 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=1.20.0" diff --git a/example/read_write_file/main.dart b/example/read_write_file/main.dart index 0397ce90..3f17ec7e 100644 --- a/example/read_write_file/main.dart +++ b/example/read_write_file/main.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:fpdart/fpdart.dart'; /** @@ -35,41 +36,35 @@ void main() async { /// /// Since we are using [TaskEither], until we call the `run` method, /// no actual reading is performed. - final task = readFileAsync('./assets/source_ita.txt').flatMap( - (linesIta) => readFileAsync('./assets/source_eng.txt').map( - (linesEng) => linesIta.zip(linesEng), - ), - ); - - /// Run the reading process - final result = await task.run(); - - /// Check for possible error in the reading process - result.fold( - /// Print error in the reading process + final task = readFileAsync('./assets/source_ita.txt') + .flatMap( + (linesIta) => readFileAsync('./assets/source_eng.txt').map( + (linesEng) => linesIta.zip(linesEng), + ), + ) + .map( + (iterable) => iterable.flatMapWithIndex( + (tuple, index) => searchWords.foldLeftWithIndex>( + [], + (acc, word, wordIndex) => + tuple.second.toLowerCase().split(' ').contains(word) + ? [ + ...acc, + FoundWord( + index, + word, + wordIndex, + tuple.second.replaceAll(word, '<\$>'), + tuple.first, + ), + ] + : acc, + ), + ), + ) + .match( (l) => print(l), - (r) { - /// If no error occurs, search the words inside each sentence - /// and compute list of sentence in which you found a word. - final list = r.flatMapWithIndex((tuple, index) { - return searchWords.foldLeftWithIndex>( - [], - (acc, word, wordIndex) => - tuple.second.toLowerCase().split(' ').contains(word) - ? [ - ...acc, - FoundWord( - index, - word, - wordIndex, - tuple.second.replaceAll(word, '<\$>'), - tuple.first, - ), - ] - : acc, - ); - }); - + (list) { /// Print all the found [FoundWord] list.forEach( (e) => print( @@ -77,6 +72,9 @@ void main() async { ); }, ); + + /// Run the reading process + await task.run(); } /// Read file content in `source` directory using [TaskEither] diff --git a/example/read_write_file/pubspec.lock b/example/read_write_file/pubspec.lock index 0b634edb..4f977987 100644 --- a/example/read_write_file/pubspec.lock +++ b/example/read_write_file/pubspec.lock @@ -7,7 +7,7 @@ packages: path: "../.." relative: true source: path - version: "0.2.1" + version: "0.3.1" lint: dependency: "direct dev" description: @@ -16,4 +16,4 @@ packages: source: hosted version: "1.5.3" sdks: - dart: ">=2.16.0 <3.0.0" + dart: ">=2.17.0 <3.0.0" diff --git a/example/src/either/cast.dart b/example/src/either/cast.dart new file mode 100644 index 00000000..f6dc0aca --- /dev/null +++ b/example/src/either/cast.dart @@ -0,0 +1,26 @@ +import 'package:fpdart/fpdart.dart'; + +void main() { + int intValue = 10; + + /// Unhandled exception: type 'int' is not a subtype of type 'List' in type cast + final waitWhat = intValue as List; + final first = waitWhat.first; + print(first); + + /// Safe 🎯 + final wellYeah = Either>.safeCast( + intValue, + (dynamic value) => 'Not an List!', + ); + final firstEither = wellYeah.map((list) => list.first); + print(firstEither); + + /// Verify using `is` + dynamic locationJson = 0; + + if (locationJson is List) { + final first = locationJson.first; + print(first); + } +} diff --git a/example/src/task/task_and_future.dart b/example/src/task/task_and_future.dart new file mode 100644 index 00000000..1678ced8 --- /dev/null +++ b/example/src/task/task_and_future.dart @@ -0,0 +1,92 @@ +import 'package:fpdart/fpdart.dart'; + +/// Helper functions ⚙️ (sync) +String addNamePrefix(String name) => "Mr. $name"; +String addEmailPrefix(String email) => "mailto:$email"; +String decodeName(int code) => "$code"; + +/// API functions 🔌 (async) +Future getUsername() => Future.value("Sandro"); +Future getEncodedName() => Future.value(10); + +Future getEmail() => Future.value("@"); + +Future sendInformation(String usernameOrName, String email) => + Future.value(true); + +Future withFuture() async { + late String usernameOrName; + late String email; + + try { + usernameOrName = await getUsername(); + } catch (e) { + try { + usernameOrName = decodeName(await getEncodedName()); + } catch (e) { + throw Exception("Missing both username and name"); + } + } + + try { + email = await getEmail(); + } catch (e) { + throw Exception("Missing email"); + } + + try { + final usernameOrNamePrefix = addNamePrefix(usernameOrName); + final emailPrefix = addEmailPrefix(email); + return await sendInformation(usernameOrNamePrefix, emailPrefix); + } catch (e) { + throw Exception("Error when sending information"); + } +} + +TaskEither withTask() => TaskEither.tryCatch( + getUsername, + (_, __) => "Missing username", + ) + .alt( + () => TaskEither.tryCatch( + getEncodedName, + (_, __) => "Missing name", + ).map( + decodeName, + ), + ) + .map( + addNamePrefix, + ) + .flatMap( + (usernameOrNamePrefix) => TaskEither.tryCatch( + getEmail, + (_, __) => "Missing email", + ) + .map( + addEmailPrefix, + ) + .flatMap( + (emailPrefix) => TaskEither.tryCatch( + () => sendInformation(usernameOrNamePrefix, emailPrefix), + (_, __) => "Error when sending information", + ), + ), + ); + +Task getTask() => Task(() async { + print("I am running [Task]..."); + return 10; + }); + +Future getFuture() async { + print("I am running [Future]..."); + return 10; +} + +void main() { + Task taskInt = getTask(); + Future futureInt = getFuture(); + + // Future taskRun = taskInt.run(); +} diff --git a/example/src/task_option/future_task_option.dart b/example/src/task_option/future_task_option.dart new file mode 100644 index 00000000..830fe73f --- /dev/null +++ b/example/src/task_option/future_task_option.dart @@ -0,0 +1,30 @@ +import 'package:fpdart/fpdart.dart'; + +late Future?> example; + +final taskOp = TaskOption.flatten( + (TaskOption.fromTask( + Task?>( + () => example, + ).map( + (ex) => Option.fromNullable(ex).toTaskOption(), + ), + )), +); + +/// New API `toOption`: from `Map?` to `Option>` +final taskOpNew = TaskOption>( + () async => (await example).toOption(), +); + +/// Using `Option.fromNullable`, the [Future] cannot fail +final taskOpNoFail = TaskOption>( + () async => Option.fromNullable(await example), +); + +/// Using `Option.fromNullable` when the [Future] can fail +final taskOpFail = TaskOption?>.tryCatch( + () => example, +).flatMap>( + (r) => Option.fromNullable(r).toTaskOption(), +); diff --git a/lib/src/either.dart b/lib/src/either.dart index d7e2b391..a9381e19 100644 --- a/lib/src/either.dart +++ b/lib/src/either.dart @@ -411,6 +411,34 @@ abstract class Either extends HKT2<_EitherHKT, L, R> } } + /// {@template fpdart_safe_cast_either} + /// Safely cast a value to type `R`. + /// + /// If `value` is not of type `R`, then return a [Left] + /// containing the result of `onError`. + /// {@endtemplate} + /// + /// Less strict version of `Either.safeCastStrict`, since `safeCast` + /// assumes the value to be `dynamic`. + /// + /// **Note**: Make sure to specify the types of [Either] (`Either.safeCast` + /// instead of `Either.safeCast`), otherwise this will always return [Right]! + factory Either.safeCast( + dynamic value, + L Function(dynamic value) onError, + ) => + Either.safeCastStrict(value, onError); + + /// {@macro fpdart_safe_cast_either} + /// + /// More strict version of `Either.safeCast`, in which also the **input value + /// type** must be specified (while in `Either.safeCast` the type is `dynamic`). + static Either safeCastStrict( + V value, + L Function(V value) onError, + ) => + value is R ? Either.of(value) : Either.left(onError(value)); + /// Try to execute `run`. If no error occurs, then return [Right]. /// Otherwise return [Left] containing the result of `onError`. /// diff --git a/lib/src/function.dart b/lib/src/function.dart index 29f4e6f8..4f63ee69 100644 --- a/lib/src/function.dart +++ b/lib/src/function.dart @@ -91,15 +91,23 @@ A Function(dynamic b) constF(A a) => (dynamic b) => a; /// /// Enables the use of partial application of functions. /// This often leads to more concise function declarations. +/// +/// The generic types are in this order: +/// 1. Type of the first function parameter +/// 2. Type of the second function parameter +/// 3. Return type of the function /// ```dart /// final addFunction = (int a, int b) => a + b; -/// final add = curry(addFunction); +/// final add = curry2(addFunction); /// /// [1, 2, 3].map(add(1)) // returns [2, 3, 4] /// ``` -C Function(B b) Function(A a) curry2(C Function(A a, B b) function) { - return (A a) => (B b) => function(a, b); -} +ReturnType Function(SecondParameter second) Function(FirstParameter first) + curry2( + ReturnType Function(FirstParameter first, SecondParameter second) function, +) => + (FirstParameter first) => + (SecondParameter second) => function(first, second); /// Converts a ternary function into unary functions. D Function(C c) Function(B b) Function(A a) curry3( diff --git a/lib/src/option.dart b/lib/src/option.dart index a684e5d7..96eacf5f 100644 --- a/lib/src/option.dart +++ b/lib/src/option.dart @@ -401,6 +401,27 @@ abstract class Option extends HKT<_OptionHKT, T> static Option fromEither(Either either) => either.match((_) => Option.none(), (r) => Some(r)); + /// {@template fpdart_safe_cast_option} + /// Safely cast a value to type `T`. + /// + /// If `value` is not of type `T`, then return a [None]. + /// {@endtemplate} + /// + /// Less strict version of `Option.safeCastStrict`, since `safeCast` + /// assumes the value to be `dynamic`. + /// + /// **Note**: Make sure to specify the type of [Option] (`Option.safeCast` + /// instead of `Option.safeCast`), otherwise this will always return [Some]! + factory Option.safeCast(dynamic value) => + Option.safeCastStrict(value); + + /// {@macro fpdart_safe_cast_option} + /// + /// More strict version of `Option.safeCast`, in which also the **input value + /// type** must be specified (while in `Option.safeCast` the type is `dynamic`). + static Option safeCastStrict(V value) => + value is T ? Option.of(value) : Option.none(); + /// Return [Some] of `value` when `predicate` applied to `value` returns `true`, /// [None] otherwise. factory Option.fromPredicate(T value, bool Function(T t) predicate) => diff --git a/lib/src/task_either.dart b/lib/src/task_either.dart index 1f14bc3b..4b48ba75 100644 --- a/lib/src/task_either.dart +++ b/lib/src/task_either.dart @@ -44,6 +44,14 @@ class TaskEither extends HKT2<_TaskEitherHKT, L, R> TaskEither bindEither(Either either) => flatMap((_) => either.toTaskEither()); + /// Chain a function that takes the current value `R` inside this [TaskEither] + /// and returns [Either]. + /// + /// Similar to `flatMap`, but `f` returns [Either] instead of [TaskEither]. + TaskEither chainEither(Either Function(R r) f) => flatMap( + (r) => f(r).toTaskEither(), + ); + /// Returns a [TaskEither] that returns a `Right(a)`. @override TaskEither pure(C a) => TaskEither(() async => Right(a)); diff --git a/pubspec.yaml b/pubspec.yaml index 74a0c55d..281c8c86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: fpdart -version: 0.3.0 +version: 0.4.0 homepage: https://www.sandromaglione.com/ repository: https://github.com/SandroMaglione/fpdart description: Functional programming in Dart and Flutter. All the main functional programming types and patterns fully documented, tested, and with examples. diff --git a/test/src/either_test.dart b/test/src/either_test.dart index 31b7a900..d617eb68 100644 --- a/test/src/either_test.dart +++ b/test/src/either_test.dart @@ -4,7 +4,18 @@ import './utils/utils.dart'; void main() { group('Either', () { - group('[Property-based testing]', () {}); + group('[Property-based testing]', () { + group("safeCast", () { + Glados2(any.int, any.letterOrDigits) + .test('always returns Right without typed parameters', + (intValue, stringValue) { + final castInt = Either.safeCast(intValue, (value) => 'Error'); + final castString = Either.safeCast(stringValue, (value) => 'Error'); + expect(castInt, isA>()); + expect(castString, isA>()); + }); + }); + }); group('is a', () { final either = Either.of(10); @@ -1090,4 +1101,45 @@ void main() { expect(result.first, ['a', 'b']); expect(result.second, [1, 2, 3]); }); + + group('safeCast', () { + test('dynamic', () { + final castInt = Either.safeCast(10, (value) => 'Error'); + final castString = Either.safeCast('abc', (value) => 'Error'); + expect(castInt, isA>()); + expect(castString, isA>()); + }); + + test('Right', () { + final cast = Either.safeCast(10, (value) => 'Error'); + cast.matchTestRight((r) { + expect(r, 10); + }); + }); + + test('Left', () { + final cast = Either.safeCast('abc', (value) => 'Error'); + cast.matchTestLeft((l) { + expect(l, "Error"); + }); + }); + }); + + group('safeCastStrict', () { + test('Right', () { + final cast = + Either.safeCastStrict(10, (value) => 'Error'); + cast.matchTestRight((r) { + expect(r, 10); + }); + }); + + test('Left', () { + final cast = + Either.safeCastStrict('abc', (value) => 'Error'); + cast.matchTestLeft((l) { + expect(l, "Error"); + }); + }); + }); } diff --git a/test/src/option_test.dart b/test/src/option_test.dart index f7b93ce4..11073e77 100644 --- a/test/src/option_test.dart +++ b/test/src/option_test.dart @@ -5,6 +5,17 @@ import './utils/utils.dart'; void main() { group('Option', () { group('[Property-based testing]', () { + group("safeCast", () { + Glados2(any.int, any.letterOrDigits) + .test('always returns Some without typed parameter', + (intValue, stringValue) { + final castInt = Option.safeCast(intValue); + final castString = Option.safeCast(stringValue); + expect(castInt, isA>()); + expect(castString, isA>()); + }); + }); + group('map', () { Glados(any.optionInt).test('should keep the same type (Some or None)', (option) { @@ -740,4 +751,39 @@ void main() { expect(map1 == map4, false); }); }); + + group('safeCast', () { + test('dynamic', () { + final castInt = Option.safeCast(10); + final castString = Option.safeCast('abc'); + expect(castInt, isA>()); + expect(castString, isA>()); + }); + + test('Some', () { + final cast = Option.safeCast(10); + cast.matchTestSome((r) { + expect(r, 10); + }); + }); + + test('None', () { + final cast = Option.safeCast('abc'); + expect(cast, isA>()); + }); + }); + + group('safeCastStrict', () { + test('Some', () { + final cast = Option.safeCastStrict(10); + cast.matchTestSome((r) { + expect(r, 10); + }); + }); + + test('None', () { + final cast = Option.safeCastStrict('abc'); + expect(cast, isA>()); + }); + }); } diff --git a/test/src/task_either_test.dart b/test/src/task_either_test.dart index 0d4ea320..e3333bf1 100644 --- a/test/src/task_either_test.dart +++ b/test/src/task_either_test.dart @@ -99,6 +99,22 @@ void main() { }); }); + group('chainEither', () { + test('Right', () async { + final task = TaskEither(() async => Either.of(10)); + final ap = task.chainEither((r) => Either.of(r + 10)); + final r = await ap.run(); + r.matchTestRight((r) => expect(r, 20)); + }); + + test('Left', () async { + final task = TaskEither(() async => Either.left('abc')); + final ap = task.chainEither((r) => Either.of(r + 10)); + final r = await ap.run(); + r.matchTestLeft((l) => expect(l, 'abc')); + }); + }); + group('bindEither', () { test('Right', () async { final task = TaskEither(() async => Either.of(10));