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)

api_urls.py
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

channel.js
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);
});

Data flow

../_images/full_stack_data_flow.svg