Server/client communication
Server API
The Kolibri server represents data as Django Models. These models are defined in models.py
files, which can be found in the folders of the different Django apps/plugins.
In Django, Model data are usually exposed to users through webpages that are generated by the Django server. To make the data available to the Kolibri client, which is a single-page app, the Models are exposed as JSON data through a REST API provided by the Django REST Framework (DRF). It’s important to remark that Kolibri limits the content types the DRF api support to be only application/json
or multipart/form-data
. This limitation is set at kolibri/core/negotiation.py
.
In the api.py
files, Django REST framework ViewSets are defined which describe how the data is made available through the REST API. Each ViewSet also requires a defined Serializer, which describes the way in which the data from the Django model is serialized into JSON and returned through the REST API. Additionally, optional filters can be applied to the ViewSet which will allow queries to filter by particular features of the data (for example by a field) or by more complex constraints, such as which group the user associated with the data belongs to. Permissions can be applied to a ViewSet, allowing the API to implicitly restrict the data that is returned, based on the currently logged in user.
The default DRF use of Serializers for serialization to JSON tends to encourage the adoption of non-performant patterns of code, particularly ones that use DRF Serializer Method Fields, which then do further queries on a per model basis inside the method. This can easily result in the N + 1 query problem, whereby the number of queries required scales with the number of entities requested in the query. To make this and other performance issues less of a concern, we have created a special ValuesViewset class defined at kolibri/core/api.py
, which relies on queryset annotation and post query processing in order to serialize all the relevant data. In addition, to prevent the inflation of full Django models into memory, all queries are done with a values call resulting in lower memory overhead.
Finally, in the api_urls.py
file, the ViewSets are given a name (through the basename
keyword argument), which sets a particular URL namespace, which is then registered and exposed when the Django server runs. Sometimes, a more complex URL scheme is used, as in the content core app, where every query is required to be prefixed by a channel id (hence the <channel_id>
placeholder in that route’s regex pattern)
router = routers.SimpleRouter()
router.register("channel", ChannelMetadataViewSet, basename="channel")
router.register(r"contentnode", ContentNodeViewset, basename="contentnode")
router.register(
r"contentnode_tree", ContentNodeTreeViewset, basename="contentnode_tree"
)
router.register(
r"contentnode_search", ContentNodeSearchViewset, basename="contentnode_search"
)
router.register(r"file", FileViewset, basename="file")
router.register(
r"contentnodeprogress", ContentNodeProgressViewset, basename="contentnodeprogress"
)
router.register(
r"contentnode_granular",
ContentNodeGranularViewset,
basename="contentnode_granular",
)
router.register(r"remotechannel", RemoteChannelViewSet, basename="remotechannel")
urlpatterns = [url(r"^", include(router.urls))]
To explore the server REST APIs, visit /api_explorer/ on the Kolibri server while running with developer settings.
Client resource layer
To access this REST API in the frontend Javascript code, an abstraction layer has been written to reduce the complexity of inferring URLs, caching resources, and saving data back to the server.
Resources
In order to access a particular REST API endpoint, a Javascript Resource has to be defined, an example is shown here
import { Resource } from 'kolibri.lib.apiResource';
export default new Resource({
name: 'channel',
});
Here, the name
property is set to 'channel'
in order to match the basename
assigned to the /channel
endpoint in api_urls.py.
If this resource is part of the core app, it can be added to a global registry of resources inside kolibri/core/assets/src/api-resources/index.js
. Otherwise, it can be imported as needed, such as in the coach reports module.
Models
The instantiated Resource can then be queried for client side representations of particular information. For a representation of a single server side Django model, we can request a Model from the Resource, using fetchModel
// corresponds to resource address /api/content/contentnode/<id>
const modelPromise = ContentNodeResource.fetchModel(id);
The argument is the database id (primary key) for the model.
We now have a reference for the promise to fetch data fron the server. To read the data, we must resolve the promise to an object representing the data
modelPromise.then((data) => {
logging.info('This is the model data: ', data);
});
The fetchModel
method returns a Promise
which resolves when the data has been successfully retrieved. This may have been due to a round trip call to the REST API, or, if the data has already been previously returned, then it will skip the call to the REST API and return a cached copy of the data.
If it is important to get data that has not been cached, you can call the fetchModel
method with a force parameter
ContentNodeResource.fetchModel(id, { force: true }).then((data) => {
logging.info('This is definitely the most up to date model data: ', data);
});
Collections
For particular views on a data table (which could range from ‘show me everything’ to ‘show me all content nodes with titles starting with “p”’) - Collections are used.
Collections are a cached view onto the data table, which are populated by Models - so if a Model that has previously been fetched from the server by a Collection is requested from getModel
, it is already cachced.
// corresponds to /api/content/contentnode/?popular=1
const collectionPromise = ContentNodeResource.fetchCollection({ getParams: { popular: 1 } });
The getParams option defines the GET parameters that are used to define the filters to be applied to the data and hence the subset of the data that the Collection represents.
We now have a reference for the promise to fetch data fron the server. To read the data, we must resolve the promise to an array of the returned data objects
collectionPromise.then((dataArray) => {
logging.info('This is the model data: ', dataArray);
});
The fetchCollection
method returns a Promise
which resolves when the data has been successfully retrieved. This may have been due to a round trip call to the REST API, or, if the data has already been previously returned, then it will skip the call to the REST API and return a cached copy of the data.
If it is important to get data that has not been cached, you can call the fetch
method with a force parameter
ContentNodeResource.fetchCollection({ getParams: { popular: 1 }, force: true }).then((dataArray) => {
logging.info('This is the model data: ', dataArray);
});