Type safe HTTP calls in Flutter with Chopper
Problem:
I am not sure about you but for me when I started working with Flutter (or dart) I had great difficulty in handling API calls and JSON responses because of lack of a native JSON support as easy as python or Js (the languages I was previously working with), I mean the data type Map<String, dynamic>
is almost equivalent to a JSON but naah. Also, I believe when you are going with a strictly typed language like dart you would like your API responses to also be dart objects rather than a random Map.
Well my current project requires a lot of communication with an Express-based API, initially I was using flutter team's own http package which was really good but adding query parameters and headers in them is terribly handled and in short, I don't like it.
That's when I came across Chopper which is dubbed as retrofit for flutter, this is what I'll be talking about in this blog
Solution: Chopper
Well chopper is a flutter package that helps you simplify your REST call
API/HTTP calls will be handled using chopper
import 'package:chopper/chopper.dart';
// the line below is strictly supposed to be <fileName>.chopper.dart
part 'chopper.chopper.dart';
@ChopperApi(baseUrl: 'api/v1/')
abstract class API extends ChopperService {
@Get()
Future<Response> getter();
@Post()
Future<Response> post(@Body() Map<String, dynamic> body);
static API create() {
final client = ChopperClient(
baseUrl: 'https://d2def3d9f930.ngrok.io/',
services: [_$API()],
converter: JsonConverter());
}
}
Well, chopper is one those dependencies which generate code for you coooool, that's why we need the part of directive.
The above snippet is the simplest implementation of the chopper packages without any type safety or interceptors. Showing only 2 calls a get and a post request both to https://d2def3d9f930.ngrok.io/api/v1
we can run the following command to build the .chopper.dart file
flutter packages pub run build_runner watch
Your Service should be an abstract class and should extend the ChopperService class
The decorator @ChopperAPI is needed as it defines the abstract class (or interface) as a ChopperService
Every call is a separate function and the method is defined by the corresponding decorator, which takes in a fixe
- path: the path duh ๐คท๐ป
- headers>: A dictionary/Map of headers apart from default ones
Dynamic decorators as parameters
This is followed by a list of parameters to the actual function, these need to specified with suitable decorators like
@Path: if you have a variable path as in you send a user id or order id in your path itself you can use this directive
@Get(path: '/hashnode/{user}') Future<Response> getUser(@Path String userId);
@Query: To send query parameters with the call
@Get(path: '/hashnode/iresharma') Future<Response> getUser(@Query('search') String searchString);
@QueryMap: To send a series of Query parameters you can use this directive to pass a Map which will be converted to multiple queries
@Get(path: '/hashnode/iresharma') Future<Response> getUser(@QueryMap() Map<String, dynamic> queries);
@Headers: To send headers along, I am not sure but this is the only way you can send dynamic headers
@Get(path: '/hashnode/iresharma') Future<Response> getUser(@Headers('search') String searchString);
@Body: To send a Body in case of POST or PUT or PATCH request
@Post(path: '/hashnode/iresharma') Future<Response> getUser(@Body() Map<String, dynamic> queries);
Let's talk about the Request
and Response
objects
Response Object
The response object is pretty simple and has only a few attributes
base
which contains the Request, statuscode and some stuff
Body
this contains body of the response. The datatype is determined by the converter you use
error
Request Object
Well here's the variable declaration for the Request class
final String method; final String baseUrl; final String url; final dynamic body; final List<PartValue> parts; final Map<String, dynamic> parameters; final Map<String, String> headers; final bool multipart;
it's pretty self-explanatory
Interceptors
Interceptors are basically a set of commands that are run with every command
All interceptors are client wide
we put interceptors inside the client
static API create() {
final client = ChopperClient(
baseUrl: 'https://d2def3d9f930.ngrok.io',
services: [_$API()],
interceptors: [],
converter: JsonConverter());
}
A few built-in interceptors are:
HeadersInterceptor
static API create() { final client = ChopperClient( baseUrl: 'https://d2def3d9f930.ngrok.io', services: [_$API()], interceptors: [ HeadersInterceptor({'Content-type': 'Application/json'}) ], converter: JsonConverter()); }
The above code will add the
'Content-type': 'Application/JSON'
header to all the requests that it makes.HttpLoggingInterceptor
We use this header to log everything that's happening with the request, but this requires some setup which is as follows:
we include the logging package which is preloaded by chopper.
Add make the following changes to main.dart
void main() { Logger.root.level = Level.ALL; Logger.root.onRecord((rec) { print('${rec.level) ${rec.message}); }); runApp(MyAPP()); }
The above code will instantiate the Logger and now we can add the
HttpLoggingInterceptor
interceptor to the Clientstatic API create() { final client = ChopperClient( baseUrl: 'https://d2def3d9f930.ngrok.io', services: [_$API()], interceptors: [ HeadersInterceptor({'Content-type': 'Application/json'}), HttpLoggingInterceptor() ], converter: JsonConverter()); }
I personally use a custom logging interceptor, this prints way more information than needed and makes the debug console very very messy
CurlInterceptor
This is not used as much but It's really cool because it prints out the curl command for the request just made
Custom interceptors
+โ async functions
+โ class-based
Async Functions
we can create both request and response interceptors and I'd suggest not using the HttpLoggingInterceptor because it logs a lot of not so important information like the whole response which may make the more important information like the route, headers and query parameter difficult to read
static API create() { final client = ChopperClient( baseUrl: 'https://d2def3d9f930.ngrok.io', services: [_$API()], interceptors: [ HeadersInterceptor({'Content-type': 'Application/json'}), // HttpLoggingInterceptor(), (Request req) async { chopperLogger.info('${req.headers}'); // VERY VERY IMPORTANT TO RETURN req return req; } ], converter: JsonConverter()); }
The above code is an example of request interceptor which logs the headers of the request, note it's very very important to return the req back from the function because the way it's made it does not give a lint but ends up crashing or compilation error
below is an example of a response interceptor
static API create() { final client = ChopperClient( baseUrl: 'https://d2def3d9f930.ngrok.io', services: [_$API()], interceptors: [ HeadersInterceptor({'Content-type': 'Application/json'}), // HttpLoggingInterceptor(), (Request req) async { chopperLogger.info('${req.headers}'); // VERY VERY IMPORTANT TO RETURN req return req; }, (Response res) { chopperLogger('${res.statusCode}'); } ], converter: JsonConverter()); }
Custom Class-based interceptors
Well according to our need we can have various reasons and functionality where we'd want to have interceptors but here we are gonna implement a simple interceptor which does not make the request if the user is on mobile data
Important to note the below example uses
connectivity
packageclass MobileDataInterceptor implements RequestInterceptor { @override FutureOr<Request> onRequest(Request req) { final connection = await connectivity().checkConnnectivity(); if(connection == ConnenctivityResult.mobile) { // throw an appropriate exception } return req; } }
Custom convertors
The code below is a generic converter
import 'dart:convert';
import 'package:chopper/chopper.dart';
class GenericConvertor extends JsonConverter {
final Map<Type, Function> typeToJsonFactoryMap;
GenericConvertor(this.typeToJsonFactoryMap);
@override
Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {
return response.copyWith(
body: fromJsonData<BodyType, InnerType>(
response.body, typeToJsonFactoryMap[InnerType]),
);
}
T fromJsonData<T, InnerType>(String jsonData, Function jsonParser) {
var jsonMap = json.decode(jsonData);
if (jsonMap is List) {
return jsonMap
.map((item) => jsonParser(item as Map<String, dynamic>) as InnerType)
.toList() as T;
}
return jsonParser(jsonMap);
}
}
How does this work ???
well the convertor is called like
GenericConvertor({
UserModel: (jsonData) => UserModel.fromJson(jsonData),
AuthCheck: (jsonData) => AuthCheck.fromJson(jsonData)
})
Basically, it takes in a Map<Type, function>
corresponding to the class and the fromJson() function for each type
Then it runs a function which copies the response object and replaces the body with the object we want
it does that running the fromJson function and encoding the object as a bodyType, as required by the response object.
Even though it handles the list but that only works if the body of your response is directly a list else it breaks, and this true for normal values as well it works only if the body is directly the object you want
For a case where the data structure is different, you use the code snippet below as reference to write a custom converter
the data i received had body['chapters'] = List so i had to use this convertor
import 'dart:convert';
import 'package:chopper/chopper.dart';
import 'package:learners/models/DataModels/Chapters/chapterModel.dart';
class SubjectConvertor extends JsonConverter {
@override
Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {
return response.copyWith(
body: fromJsonData<BodyType, InnerType>(response.body),
);
}
T fromJsonData<T, InnerType>(String jsonData) {
var jsonMap = json.decode(jsonData);
List<dynamic> chapters = jsonMap['chapters'];
if (chapters == null) {
return ChapterModel.fromJson(jsonMap['chapter']) as T;
}
return chapters.map((e) => ChapterModel.fromJson(e)).toList() as T;
}
}