When using API-based services, they often bring an SDK for some of the most popular languages, to facilitate the creation of apps using their API (e.g. Supabase or Google Ads). This blog post describes a concept for building your own SDK for consumption of any REST API, using the Dart programming language for use in an app using the Flutter framework.
Table of contents
Terminology
The following table gives an overview of the abbreviations and technical terms used in the blog post.
Term | Description |
---|---|
API | Application Programming Interface; an interface of a software system that allows other software systems to use it. |
SDK | Software Development Kit; a library that provides access to a third-party service for the respective programming language. |
REST API | Short form for RESTful API which denotes a software architecture style based "representational state transfer". |
KSCH | Kirpal Sagar Charitable Hospital; the target domain of the app to be built. |
Workflow | A semi-automated business process. |
Context
The backend service of the KSCH Workflows system provides a REST API for all the queries and commands which are needed for the apps in the KSCH workflows. The KSCH Dart Client facilitates it for all apps which use the Dart programming language to use that API by wrapping the low-level REST API with a high-level Dart API. The apps - e.g. the Registration desk, Pharmacy desk, and Administration app - can then use the KSCH Dart Client like any other Dart package.
In the text below, the KSCH Dart Client will also be referred to as "the SDK".
Usage
Before the discussion of the internal structure of the KSCH Dart Client, it will be shown how to use it.
Package dependency declaration
The KSCH Dart Client is a project-specific library that is of no use for the general Dart community. That's why the package is not published to the Dart package repository but is leveraging Dart's capability to declare dependencies directly on GitHub repositories.
dependencies:
ksch_dart_client:
git:
url: https://github.com/ksch-workflows/ksch_dart_client
ref: a85c7f0cb83087d13e207b1331bcf6f64676e995
Once the system is in production use, semantic versioning tags should be used instead of specific commit IDs.
Instantiate API entry point
The next step in using the SDK is to create an instance of the KschApi
class. This class has a parameter for the base URL of the backend service so that it can be configured to be executed against the testing or production system.
KschApi api = KschApi('http://localhost:8080');
Collection resources
The first request to be done with the API is listing all patients with a GET request on the /patients
collection resource.
http GET /patients
The basic idea of the SDK is that it provides a property in the api
object for every supported resource. So, in the case of this example, there is a patients
property. On its value is a method available for each operation that can be done on that resource. By calling this method, the HTTP call is triggered and delivers its response wrapped in a Future
.
await api.patients.list();
Since collection resources are usually paged, the list
method can take the index of the page to be requested as an optional parameter.
late PatientsResponsePayload response;
var page = 0;
do {
response = await api.patients.list(page: page++);
for (var patient in response.patients) {
print(patient.id);
}
} while (response.hasNextPage);
Singleton resources
For working with individual patients, the singleton resource can be used.
http GET /patients/${PATIENT_ID}
With the help of Dart's call
method convention it is possible to invoke an object as a function. With this tool, a parameter can be passed to a collection resource for fluent access on a singleton resource below.
var response = await api.patients(patientId).get();
print(response.id);
print(response.name);
print(response.residentialAddress);
Each resource may also have sub-resources. All resource path elements act as a builder and the HTTP call is triggered only in the final method call.
await api.patients(patientId).visits.startVisit(VisitType.OPD);
Error handling
When there is an error response from the API, i.e. a status code >= 400, then an exception will be raised.
Usage in Flutter project
To avoid any HTTP-related ideas and too much business logic within the frontend widgets, the SDK is wrapped in a service layer. For every service, there is an interface which is widgets uses. During application start the main.dart
file decides which service implementation should be used. Only that service implementation knows about the SDK and how to use it.
The SDK can now be used in any Dart project. To spare the Flutter code of dealing with the request details, the SDK can be wrapped into service classes.
class PatientServiceImpl implements PatientService {
final KschApi _api;
PatientServiceImpl(KschApi api) : _api = api;
@override
Future<Patient> get(String patientId) async {
var response = await _api.patients(patientId).get();
return Patient.from(response);
}
}
The service can then be injected into the widget using e.g. get_it package.
class _RegisterPatientPageState extends State<RegisterPatientPage> {
final PatientService patientService = GetIt.I<PatientService>();
@override
Widget build(BuildContext context) {
return WebScaffold(
title: 'Register patient',
// ...
floatingActionButton: FloatingActionButton(
onPressed: () async {
// ...
final RegisterPatientResult? result = await showDialog(
//...
);
if (result != null) {
final createdPatient = await patientService.create(result.patient);
// ...
}
},
// ...
),
);
}
}
Testing
Service tests
The service classes can be tested with the help of the nock package.
void main() {
late PatientServiceImpl patientService;
setUpAll(nock.init);
setUp(() {
nock.cleanAll();
patientService = PatientServiceImpl('http://localhost');
});
test('Should create patient in case of emergency', () async {
var patientId = const Uuid().v4();
givenCreatePatientResponse(MinimalPatientResponse(patientId).toJson());
var result = await patientService.create(null);
expect(result.id, equals(patientId));
});
}
void givenCreatePatientResponse(dynamic body) {
nock('http://localhost').post('/api/patients')..reply(200, body);
}
Widget tests
For the unit tests of the Flutter widgets, it should be fairly simple to create mocks for the used services.
Implementation details
Next comes a description of how the SDK is working internally.
Overview
The KSCH Dart Client as a small core with the KschApi
class and model classes to be able to interpret pagination and links in the response payloads. The KschApi
API provides an API for basic HTTP operations which can then be used by the resources. Further, it provides properties to the root resources.
The resources themselves are a tree structure, i.e. every resource can have subresources. Further, they know about their respective path element. Just before the actual HTTP called on the absolutePath
, all the path elements of all ancestors are joined together.
The bases classes for the collection resources and identity resources are almost the same, with the difference that
identity resources know about their identification number.
Payload representations
The KSCH API uses JSON as the data format for request and response payloads. The code which does the mapping between JSON strings and Dart data types is generated with the help of the json_annotation library.
The Dart data types are a model of the JSON data structures. By default, the JSON properties are mapped with the Dart properties. If this is not possible, e.g. because a property in the JSON data starts with an underscore, then the mapping can be configured with custom annotation.
@JsonSerializable()
class VisitResponsePayload {
@JsonKey(name: '_id')
final String id;
final VisitType type;
final String opdNumber;
final DateTime timeStart;
VisitResponsePayload({
required this.id,
required this.type,
required this.opdNumber,
required this.timeStart,
});
factory VisitResponsePayload.fromJson(Map<String, dynamic> json) =>
_$VisitResponsePayloadFromJson(json);
Map<String, dynamic> toJson() => _$VisitResponsePayloadToJson(this);
}
The details of the fromJson
and toJson
methods are then generated from the Dart build runner.
dart run build_runner build
Tests
The unit tests for the SDK act also as API tests for the backend API. The expectation is that the backend has been locally started on port 8080. Then they are using the real API to make sure that all requests can be successfully executed and all responses successfully parsed. Later on, this test suite will also be included in the build process of the backend to make sure that no unintended breaking API changes are made.
Top comments (0)