connection_manager 1.2.2 connection_manager: ^1.2.2 copied to clipboard
A simple connection manager to work with API and network calls
This package provides a simple implementation of a Connection Manager to do API request to a Server (REST or GraphQL). Furthermore, it provides an ApiCallBuilder widget to easily integrate API calls in the widget tree and a PaginatedApiCallBuilder widget to easily integrate paginated API calls in the widget tree.
Features #
This package contains:
-
ConnectionManager
A class to make API requests, created setting a baseurl and headers to use for all the API calls. -
PostApiResponse
A class to manage responses from the ConnectionManager
, decoded by the provided class.
- Decodable
A class to implement to let this package decode custom classes from a Map.
- ApiCallBuilder
A widget to easily integrate API calls in the widget tree.
- PaginatedApiCallBuilder
A widget to easily integrate paginated API calls in the widget tree.
Usage #
Check the usage paragraph according to your needs.
Setting up #
Before using the ConnectionManager
, it must be initialized providing a baseUrl and headers. It can be useful to save a single instance of the ConnectionManager
to be used all through the app, for example as a singleton.
To initialize the class:
ConnectionManager(
baseUrl: "https://my-base-url.com",
constantHeaders: {
"Content-Type": "application/json",
},
decodeErrorFromMap: CustomError.fromMapError, // Optional, to let the package try to automatically decode errors from server. It's a method passed as a tear off
mapStatusCodeFromResponse: (map) => map?["code"], // Optional, you can use this method to map a code from body and use it to override the http status code.
onTokenExpiredRuleOverride: (response) {
if (response.statusCode == 500 && response.body.contains("missing auth")) {
return true;
}
return false;
}, /// Optional, this method can be used in combination with `onTokenExpired` to define a custom rule to trigger the `onTokenExpired` method. By default, `onTokenExpired` is fired when the http response has a 401 status code. Eventually, this rule can be expanded thanks to this method.
onTokenExpired: () async {
return await refreshToken(); // refreshToken is not a method of this package
}, // A function fired when the http client gives a 401 response after an API call. It is used to refresh the auth token, if set, and after returning the new token the [ConnectionManager] will attempt the API call once again.
onResponseReceived: (Response response) {
print(response.body);
}, // A function fired, if not _null_, when the `doApiRequest` method receives a response from the BE. This can be useful to manage broadly a `Response` the same way for every api call.
returnCatchedErrorMessage: true, // Specify if the error message coming from the try-catch block in `doApiRequest` should be returned in the response (i.e. decoding errors). Default to _true_.
duration: const Duration(seconds: 1), // Specify the timeout for all the API calls done with this [ConnectionManager]. Defaults to 1 minute.
persistCookies: false, // If _true_, creates a persistent instance of a cookie manager to be used for all the API calls done with this [ConnectionManager]. Defaults to _false_.
client: Client(), // If set, overrides the default http client for API calls
)
In the example above, CustomError
is a local class that implements Decodable
in this package and its fromMapError
method. See Decodable
documentation for further details.
You can store a single instance of the ConnectionManager
as a Provider or as a singleton. Check this example:
class NetworkProvider {
final String baseUrl;
// Connection Manager definition
final _connectionManager = ConnectionManager<CustomError>(
baseUrl: baseUrl,
constantHeaders: {"Content-Type": "application/json"},
decodeErrorFromMap: CustomError.fromMapError,
onTokenExpired: () async {
return await refreshToken(); // refreshToken() is not a method of this package
},
onResponseReceived: (Response response) {
print(response.body);
},
returnCatchedErrorMessage: true,
);
// Connection Manager getter
ConnectionManager<CustomError> get connectionManager => _connectionManager;
}
// Use the provider
class MyApp extends StatelessWidget {
@override build(BuildContext context) {
return Provider(
create: (context) => NetworkProvider(
baseUrl: "https://test.com/api",
),
child: Builder(
builder: (context) {
var networkProvider = context.read<NetworkProvider>();
return Text(networkProvider.baseUrl);
}
),
);
}
}
Modify ConnectionManager #
Other than passing constant headers to the ConnectionManager
constructor, it is possible to add/remove extra headers to be used for all the API calls by calling one of the following methods on the created instance.
context.read<NetworkProvider>().connectionManager.setSharedHeaders({
"Authorization" : "token"
});
context.read<NetworkProvider>().connectionManager.setAuthHeader("Bearer token");
context.read<NetworkProvider>().connectionManager.removeAuthHeader();
Furthermore, it is possibile to edit the baseurl
context.read<NetworkProvider>().connectionManager.changeBaseUrl("https://test.com/api/v1");
Make an api request #
To make an API request simply call the method doAPIRequest
on the ConnectionManager
, passing all the required parameters, and eventually other headers or a body.
The method is asyncronous, and it will return a PostApiResponse
as a Future
, containing the decoded content, the http status code and eventually an error message.
Other than specifying the request type (get, post...), it is possible to specify the body type: json, formdata, graphQL... To do so, use the bodyType
parameter (defaults to json type). Note: When passing a json body, it's mandatory to json encode the Map, as follows.
var response = await context.read<NetworkProvider>().connectionManager.doApiRequest(
requestType: ApiRequestType.post,
endpoint: "/test-endpoint",
body: jsonEncode({
"test": "test"
}),
);
When using a formData body, it's mandatory to pass it as a Map<String,dynamic>
. To pass a file, use the FileData
class provided by this library to create a file and add it as a vaue of the Map. It's left to the package to manage it correctly.
Whenn using a graphQL body, it's mandatory to pass it as a [String]. Parameters must be passed as values in the string itself. The [ApiRequestType] should be get for queries or anything else for mutations.
var postApiResponse = await context.read<NetworkProvider>().connectionManager.doApiRequest(
requestType: ApiRequestType.get,
bodyType: ApiBodyType.json, // Optional, the type of the body for the request (json, formdata...)
endpoint: "/my-endpoint",
headers: { // Optional, this Map headers are added to the ConnectionManager headers
"Authentication": "xxx",
},
body: { // Optional, the body of the request
"content": "xxx",
},
query: { // Optional, query paramters appended to the endpoint
"query": "test",
},
decodeContentFromMap: User.fromMap, // Optional, a method to automatically decode the response model, of type [T], passed as _tear-off_
filterMapResponseToDecodeContent: (mapResponse) {
return mapResponse["items"];
}, // Optional, a key from the original json response map (retrieved as argument of this method) can be specificied to try to the decode the content. This is useful, for example, when the response body has many nested keys but we need to decode a specific one, also deep in the json tree
decodeErrorFromMapOverride: CustomError.fromMap, // Optional, a method to automatically decode the error response model, of type [E], passed as _tear-off_ that overrides the method specified in [ConnectionManager] constructor
unescapeHtmlCodes: false, // Boolean value to eventually unescape html chars in response, defaults to _false_
tryRefreshToken: true, // Boolean value to refresh the auth token and retry the API call when the http status code is 401. Defaluts to _true_.
useUtf8Decoding: false, // Boolean value to eventyally decode the response with utf8 directly to the bytes, ignoring the body. Defaluts to _false_.
timeout: const Duration(seconds: 30), // the timeout for the API call, overrides that of the [ConnectionManager].
uploadPercentage: (percentage) => print(percentage), // it's used to retrieve the upload percentage status for _formData_ bodies. It's ignored for other _bodyTypes_.
validateStatus: (status) => true, // it's used to evaluate response status code and manage it as success/error accordingly. Simply return _true_ or _false_ depending on the _status_. Note that status codes between 200 and 299 are always accepted as successfull.
downloadProgress: (downloadedBytes, totalBytes, percentage) => print(percentage), // Optional, it's used to retrieve the download percentage status for responses from BE. It has three arguments: download bytes, total bytes count and percentage downloaded.
cancelToken: null, // Optional, it's eventually used to cancel the http request before awaiting termination. It does not work for _graphql_ requests.
)
PostApiResponse #
You can use the PostApiResponse
class to easily return the response data of the specified type.
PostApiResponse<User, GenericError>(
decodedBody: User, // The body eventually decoded in the provided class, if success
decodedBodyAsList: List<User>, // The body eventually decoded in the provided class a list, if success (useful when API response is a List instead of a Map)
decodedErrorBody: GenericError, // The body eventually decoded in the provided error class, if error
rawValue: dynamic, // The raw body of the response
originalResponse: http.Response, // The original http response of the API call
statusCode: int, // The http response status cose
hasError: bool, // A boolean value to indicate if there was an error
message: String?, // A String containing the error message, if any
)
ApiCallBuilder #
To easily integrate a widget that does an API call in your widget tree, you can use ApiCallBuilder
. It's a widget that using bloc shows a loader while performing the provided API call and then returns the response in a builder that must return the widget to show on completion. The ApiCallBuilder
must be used together with the ConnectionManager
as it accept as input the doApiRequest
method as shown below.
ApiCallBuilder<User, Error>(
apiCall: () => context.read<NetworkProvider>().doApiRequest(
requestType: ApiRequestType.get,
endpoint: "/test-endpoint",
),
builder: (context, response, responseList) {
return Text(response.toString());
},
loaderBuilder: (context) => Loader(), // Optional, defaults to a black loader spinner
errorBuilder: (context, errorMessage) => Text(errorMessage ?? "Generic error"), // Optional, defaults to a Text displaying the error message
emptyDataBuilder: (context) => Text("No data"), /// Optional, a widget to manage the empty state can be provided. If not provided, the `builder` will be used
);
As soon as the widget is created, the api call is triggered. If you want to trigger the API call again, simply call:
context.read<SingleApiCallCubit<Decodable,Decodable>>().startApiCall();
Note that you can specify a child
argument to always show some widgets while data is loading. Check the documentation for further details.
PaginatedApiCallBuilder #
To manage transparently a paginated API call in the widget tree, you can use PaginatedApiCallBuilder
. It shows a loading widget while performing the request and provides access to the response in the builder
parameter, to show a proper widget on http call completion. Furthermore, it can manage pagination while scrolling.
It must be used with the [ConnectionManager], that is what manages API call and states through a bloc component.
[T] and [E] are, respectively, the class to decode in the success response and the class to decode for an error response. If not provided, the builder will have a generic [Object] as argument, that then should be casted to use.
Future<PaginatedAPIResponse<User, Error>> doApiRequest(
int page, Map<String, String>? query) async {
var response = await context.read<NetworkProvider>().connectionManager.doApiRequest(
requestType: ApiRequestType.get,
endpoint: "/test-endpoint",
decodeContentFromMap: User.fromMap,
);
if (response.hasError) {
return PaginatedAPIResponse.error(response: response);
}
return PaginatedAPIResponse.success(response.decodedBody.data ?? [], // Note that `response.decodedBody.data` depends on your decoded model, this is an example
response: response, page: page, pageSize: 25);
}
PaginatedApiCallBuilder<User, Error>(
apiCall: doApiRequest,
builder: (context, response) {
return Text(response.toString());
},
loaderBuilder: (context) => Text("Loading"), // Optional, the widget to show while fetching data
errorBuilder: (context, errorMessage) => Text(errorMessage ?? "Generic error"), // Optional, the widget to show if the api calls terminates with errors, eventually with an error message
initialQuery: {'limit':'10'}, // Optionally, a query for the api calls can be set as default. It can be overriden when calling `startApiCall` directly or when initializing the [ScrollController].
initialPage: 0, // The initial page for pagination. Usually pagination starts from 0 (default value), but a different number can be specified to be used as first page.
);
As soon as the widget is created, the api call for the first page is triggered. The package itself is responsible of managing pages, so to execute new api calls for sequent pages, you simply call:
context.read<PaginatedApiCallCubit<Decodable, Decodable>>()>.startApiCall();
Where Decodable
should be the [T] and [E] classes you defined in the PaginatedApiCallBuilder
constructors. Note that to use context.read()
method you must import the provider package. Check the docs to know how to properly use it.
To reset data simply call:
context.read<PaginatedApiCallCubit<Decodable, Decodable>>()>.reset();
Eventually, also a new query filter can be set, as follows:
context.read<PaginatedApiCallCubit<Decodable, Decodable>>()>.reset(withQuery: {"pageSize":"10"});
To enable automatic pagination on scrolling, pass a controller to the ScrollView you want to paginated as shown in the following example:
return ListView(
controller: context.read<PaginatedApiCallCubit<Decodable, Decodable>>()>.initScrollController();
...
);
Either in the startApiCall
method or the initScrollController
method, you can override the initial query set in the PaginatedApiCallBuilder
constructor by passing an optional [Map<String,String>] argument. You can than access this query, as well as the new page calculated by the package, when specifying the method to pass as the apiCall
in the PaginatedApiCallBuilder
constructor, as shown in the first piece of code of this paragraph.
You can specify that instead of appending new data to the original response, the package should entirely wipe old data and substitute it with data from the new page, as shown in the following example:
context.read<PaginatedApiCallCubit<Decodable, Decodable>>()>.startApiCallAndReplaceData();
If you do not specify arguments, by default the next page will be fetched. You can however specify a page, or that the pagination should go back/forward from actual page leaving calculations to the package. If you do not provide newPage
than the next page is taken by default if goToPreviousPage
is not true.
Test #
For test purposes or to simulate mocked responses, you can use ConnectionManagerStub
. It is equivalent to the ConnectionManager
(both extend BaseConnectionManager
), with some little differences explained below.
final _connectionManager = ConnectionManagerStub<CustomError>(
decodeErrorFromMap: CustomError.fromMapError,
onTokenExpired: () async {
return await refreshToken(); // refreshToken() is not a method of this package
},
onResponseReceived: (Response response) {
print(response.body);
},
returnCatchedErrorMessage: true,
awaitResponse: true, // Optionally, simulate waiting 2 seconds for receiving response from BE
responseStatusCode: 500, /// Optionally, override all the http response status code for the API requests with a custom status code. If _null_ does not override status codes.
);
When creating an API request, you should pass a json file from the project assets to be used as response from an API call instead of the usual endpoint. The other parameters will be used as for a real API request.
var postApiResponse = await context.read<NetworkProvider>().connectionManager.doApiRequest(
requestType: ApiRequestType.get,
bodyType: ApiBodyType.json, // Optional, the type of the body for the request (json, formdata...)
endpoint: "mocks/test.json",
)
Furthermore, when using ConnectionManagerStub
you can specify a different status code expected for the next http response by calling the mockResponseStatus
method. Note that response status code is reset to 200 after the following API call.
final res = await context.read<NetworkProvider>().connectionManager
.mockResponseStatus(statusCode: 404)
.doApiRequest(endpoint: "mocks/test.json");
Additional information #
This package is mantained by the Competence Center Flutter of Mobilesoft Srl.