libkazv
|
libkazv is a sans-io library based on lager implementing the Matrix client-server API.
You should probably read the lager docs first: https://github.com/arximboldi/lager.
The basics of lager is that you can think of it as a state machine. The model is a value that represents the application state.
Actions are values that describe changes that are to be made to the model. Usually, there are more than one kind of actions, and we can unify them with a variant
:
A reducer is a function taking a model and an action, and returns another model and an effect. The reducer applies the changes described in the action onto the model:
The effect is then executed against a context to perform side-effects:
Effects are optional, though. The reducer may also return only a model and no effect.
There is a layer above this: the store contains the model and tracks the changes to it. The store constructor takes an event loop, which accepts posts of functions. They are run in a queue, and no more than one post can run at the same time. The time frame in which one post is run is called a tick of the event loop.
A store contains a context which effects are executed against. Contexts can be used to dispatch actions. A dispatch schedules (posts) the following in one tick of the event loop:
Here is roughly what happens when an action is dispatched:
The store can contain dependencies when it is created, and these dependencies are available in the context as well:
These dependencies can then be retrieved from the context in the effects. This is commonly used to perform IO:
On the other hand, the current value of the model can be obtained from the store via a cursor. For the purpose of the Kazv Project, the most-used cursor is a read-only one called lager::reader
. The get()
method on the cursor returns the current value stored in it.
Cursors can be watched. When the value in a cursor changes, the watch callback is invoked (in the event loop):
Cursors can also be derived. There are plenty of derivations possible, but the most used ones are lenses and map-transformations. Map-transformations are simple, it takes a function and returns another cursor:
Lenses are relationships between two values (usually a whole and a part). For the purpose of libkazv, you can see it as a similar transformation as map
, but more concise. Commonly used lenses include:
at
lenses: optional
: Cursors can be combined with lager::with()
:
Note that all cursor operations (get()
and transformations) need to happen in the thread of the event loop. (see https://github.com/arximboldi/lager/issues/118) If you need to operate on cursors in another thread, you need to create a secondary root. This is done by creating another store with an event loop running in the desired thread, with the action type being the same as the model type, and then watching the primary root:
This pattern can be used when, for example, the primary store's event loop is running in a worker thread, and the data needs to be passed to the UI thread.
libkazv is sans-io, as it has a business logic layer (the models/reducers in kazvclient and kazvcrypto) and an abstract IO layer (Sdk/Client/Room, JobInterface/EventInterface/PromiseInterface).
The models (SdkModel, ClientModel, RoomModel, etc.) represent the state of the client. Kazv::ClientModel
not only contains the application state, but also describes temporary things such as which jobs (network requests) to be sent and which triggers (e.g. notifications) to be emitted.
The out-most Kazv::SdkModel
provides an interface to save and load the state with boost::serialization
. Its reducer also takes out Kazv::ClientModel::nextJobs
and Kazv::ClientModel::nextTriggers
, and send them in the effect returned.
libkazv uses a stronger store (Kazv::Store
) than lager's builtin store, allowing then-continuations (see https://github.com/arximboldi/lager/issues/96). We take the concept of Promises from Promises/A+. It is similar to C++'s std::future
. A Promise
can be then-ed with a continuation callback, which is executed after the Promise resolves. A Promise can contain data, and the Promise is said to have resolved when the data is ready. For the case of libkazv, the data contained is of type Kazv::EffectStatus
.
The context can create a resolved Promise:
A Promise can be created by the context (Kazv::Context
) through a callback:
The resolve()
function can be also called with another Promise. This creates a Promise that is set to wait for that Promise. A Promise, p1
, that waits for another Promise p2
will resolve after p2
has resolved, and will contain the same data as p2
.
Moreover, in libkazv's store, the effect can return a Promise as well. If this is the case, the Promise returned from the dispatch will be set to wait for the Promise returned from the effect.
Actually, this is what exactly the reducer of Kazv::SdkModel
(Kazv::SdkModel::update
) does: it returns an effect that returns a Promise that resolves after all the jobs in Kazv::ClientModel::nextJobs
have been sent and we get a response for each of them.
First, choose an action name, and add it to src/client/clientfwd.hpp . You need to add both the forward declaration and the Kazv::ClientAction
variant. They are grouped by types so try to add it next to related actions.
Then, think of what parameters the action takes, and add the struct definition to src/client/client-model.hpp . If this is an API request, it should usually contain all the parameters by the corresponding job constructor in Kazv::Api
(except for the server url and the access token, which are already given in the ClientModel
.
Start writing a reducer for your action. In libkazv, the reducer for ClientAction
s lies in src/client/actions . They are also grouped by types into files, so choose a file with relevant actions to begin with. If there are none, create a new file. In the hpp file, write at least two function signatures:
where XXXAction
is the name of your action, and YYY
is the name of the job in Kazv::Api
. For example, the response type of LoginJob
is LoginResponse
. Be sure to include relevant headers in src/api/csapi .
In the cpp file, write the definitions of the updateClient()
function. It needs to add a job from the action.
Write a unit test to ensure the correct request is made. This is important because we auto-generate the C++ code for the jobs, and the order of the parameters in the job constructor can differ from version to version of the Matrix spec docs. We use Catch2-3 for unit testing.
If needed, add the test cpp file to src/tests/CMakeLists.txt
.
The next step is to think about how to process the response returned from the server. Does it need to modify any client state, or will the state change be returned from sync? In either case, you need to do error handling. This can be done conveniently with helper functions.
It is important to add the processing of the response to src/client/client-model.cpp , in the reducer for ProcessResponseAction.
If you find that processing the response is only possible if you have access to the data that is not present in the response, but only present in the action, you may add additional data to the job in the updateClient()
function:
And then the response will contain the data json from the corresponding job:
Similarly, add a test to verify it has the correct handling:
And from here you are done.
Here is a real-world example of adding an action to libkazv: https://lily-is.land/kazv/libkazv/-/merge_requests/78/diffs.