Ce document présente cozy-client
à la fois dans son état actuel et dans sa vision, et propose une roadmap des évolutions souhaitables. L'objectif est double : informer et recueillir du feedback d'une part, mais aussi et surtout permettre à l'ensemble des devs front Cozy de contribuer à cette évolution.
- Origine du projet
- Présentation
- Evolution
- Vision
- Premiers pas
- Se débarrasser du middleware spécifique
- Evolution de
cozyConnect
et des props injectées [BREAKING CHANGE!] - Empêcher une même requête d'être exécutée plusieurs fois en parallèle
- Injecter plus d'actions de mise à jour via
cozyConnect
- Simplification/généralisation des reducers et actions [ASSEZ GROS MORCEAU]
- Par la suite
cozy-client
(anciennement redux-cozy-client
) est né de la volonté de limiter au maximum le boilerplate typique des actions asynchrones avec redux-thunk
dans nos applications et de centraliser dans le store redux
l'état des différents fetches exécutés via cozy-client-js
et d'y stocker les documents fetchés de manière normalisée. Il va donc au delà de cozy-client-js
en proposant un moyen de "brancher" des composants (p)React sur des données issues de la stack cozy et de disposer de toutes les infos nécessaires pour l'UI (la requête est-elle en cours / doit-on afficher un spinner ? Y a t-il d'autres documents à fetcher ? Combien y en t-il au total sur le serveur ? etc...).
cozy-client
est actuellement fait de 4 parties :
- une couche basse, le client en lui-même, qui s'appuie sur 2 adapters pour récupérer les données depuis 2 sources différentes :
-
CozyStackAdapter
qui wrappecozy-client-js
pour récupérer des données depuis la stack, -PouchDBAdapter
pour récupérer des données depuis un pouch local synchronisé avec la stack. Le client se base sur sa config (doctypes.offline
) pour déterminer l'adapter à utiliser pour chaque appel.
import { CozyClient } from 'cozy-client'
const client = new CozyClient({...})
client.fetchDocument('io.cozy.todos', 'e8354db7abfef08b7a14e43b2b106a9d')
.then(document => { /* do something with the document */ })
- une couche haute qui expose des sélecteurs et créateurs d'actions
redux
. Afin de réduire le boilerplate, les actions crées sont d'un format spécifique àcozy-client
et nécessite la mise en place d'un middleware spécifique pour être traitées. En effet, on distingue traditionnellement 2 types d'action creatorsredux
: - les synchrones :
export const receiveData = (data) => ({
type: RECEIVE_DATA,
data
})
- les asynchrones, qui nécessitent le middleware redux-thunk
et que l'on utilise pour fetcher des données en dispatchant des action synchrones aux instants clés du cycle de vie du fetch :
export const fetchDocument = (doctype, id) => (dispatch, getState) => {
dispatch({ type: FETCH_DOCUMENT, doctype, id })
return client.fetchDocument(doctype, id)
.then(resp => dispatch({ type: RECEIVE_DATA, data: resp.data }))
.catch(err => dispatch({ type: RECEIVE_ERROR, error: err }))
}
Les actions asynchrones deviennent vite verbeuses, et pourtant elles ont un motif commun : on dispatche une action avant d'exécuter le fetch, on dispatche une action en cas de succès, et une autre en cas d'erreur. Afin d'alléger ces action creators, cozy-client
expose des actions creators qui n'ont pas de propriété type
mais types
et une propriété promise
contenant une fonction exécutable avec une instance du client passée en argument :
export const fetchDocument = (doctype, id) => ({
types: [FETCH_DOCUMENT, RECEIVE_DATA, RECEIVE_ERROR],
doctype,
id,
promise: client => client.fetchDocument(doctype, id)
})
-
le middleware
redux
decozy-client
, qui reçoit une instance du client lors de son instantiation, reconnait ce format d'action et exécute lapromise
en dispatchant les actionstypes
:const { types, promise } = action const [REQUEST, SUCCESS, FAILURE] = types next({ ...rest, type: REQUEST }) return promise(client, dispatch, getState)) .then( response => { next({ ...rest, response, type: SUCCESS }) return response }, error => { console.log(error) next({ ...rest, error, type: FAILURE }) } ) .catch(error => { console.error('MIDDLEWARE ERROR:', error) next({ ...rest, error, type: FAILURE }) }) }
-
un HOC,
cozyConnect
, qui permet à un composant de décrire les données dont il a besoin (c'est la vision en tout cas). Concrètement, il permet de se passer du boilerplate typique de l'utilisation duconnect
dereact-redux
, à savoir dispatcher les actions de fetches injectées grâce aumapDispatchToProps
dans lecomponentDidMount
.
import React from 'react'
import { cozyConnect, fetchCollection } from 'cozy-client'
const TodoList = ({ todos }) => {
const { data, fetchStatus } = todos
if (fetchStatus !== 'loaded') {
return <h1>Loading...</h1>
}
return (
<ul>
{data.map(todo => <li>{todo.label}</li>)}
</ul>
)
}
const mapDocumentsToProps = (ownProps) => ({
todos: fetchCollection('todos', 'io.cozy.todos')
})
export default cozyConnect(mapDocumentsToProps)(TodoList)
When we use cozyConnect
to wrap a component, three things happen:
- The actions specified by the
mapDocumentsToProps
function (here thefetchCollection
call) will be dispatched when the component mounts, resulting in the loading of data from the client-side store, or the server if the data is not in the store - Our component subscribes to the store, so that it is updated if the data changes
- props are injected into the component: in our case, a
todos
prop. If we were to declarepropTypes
they would look like this:
TodoList.propTypes = {
todos: PropTypes.shape({
fetchStatus: PropTypes.string.isRequired,
data: PropTypes.array
})
}
As seen above, cozyConnect
will pass the result of the collection fetch to the wrapped component in a prop whose name is specified in the mapDocumentsToProps
function. For collections fetches, the shape of the injected prop is the following:
data
: an array of documentsfetchStatus
: the status of the fetch (pending
,loading
,loaded
orerror
)lastFetch
: when the last fetch occuredhasMore
: the fetches being paginated, this property indicates if there are more documents to loadfetchMore
: a function you can call to trigger the fetching of the next page of data
De manière générale, on a besoin pour un composant de fetcher un unique document (et ses relations, mais ceci est une autre histoire) ou une liste (filtrée et/ou triée ou non) de documents d'un même doctype, que l'on appellera collection. fetchCollection
est l'action creator dont le dispatch permet de récupérer une liste de documents. Son premier argument est le nom de la collection (par exemple timeline
pour la liste des photos récentes), le deuxième le doctype et le troisième les options de la requête :
export default cozyConnect(ownProps => ({
photos: fetchCollection(
'timeline',
'io.cozy.files',
{
fields: ['dir_id', 'name', 'size', 'updated_at', 'metadata'],
selector: {
class: 'image',
trashed: false
},
sort: {
'metadata.datetime': 'desc'
}
}
)
}))(Timeline)
Afin de persister le résultat de ces fetches dans le store de manière normalisée, on stocke d'un côté tous les documents regroupés par doctypes et de l'autre des listes d'IDs correspondant aux collections fetchées. On a donc un store ressemblant à :
{
cozy: {
collections: {
timeline: {
count: 214,
fetchStatus: 'loaded',
hasMore: true,
ids: [
...
],
lastFetch: ...,
options: ...,
type: 'io.cozy.files'
}
},
documents: {
'io.cozy.files': {
0e80e10d37e791a94ccb4f7cff2a9cfe: {
_id: 0e80e10d37e791a94ccb4f7cff2a9cfe,
...
}
}
},
sharings: {
documents: [],
permissions: {}
},
synchronization: {
doctypes: {},
initialStatus: 'pending',
started: false
}
}
}
Comme vous pouvez le constater ci-dessus, en raison de certaines spécificités de l'API du partage, les sharings
et permissions
sont persistés à part. C'est loin d'être idéal, et suite aux derniers travaux du back sur cette API, on pourrait envisager de normaliser ça.
L'architecture actuelle de cozy-client
nous a déjà permis de bien réduire le boilerplate redux
dans Photos, mais elle présente 2 problèmes :
- une certaine rigidité (format des actions par ex.) qui pousse trop de responsabilités vers la couche basse, le client (choix de l'adaptateur et donc de la source de données par ex.). Conséquence : l'ajout de certaines fonctionnalités est plus compliqué ;
- beaucoup de "pièces mobiles" (actions, selectors) sont exposées, ce qui complique les refactos de la lib, et son utilisation par les devs.
Une influence majeure dans le développement de cozy-client
fût Apollo, un client GraphQL pour JS, et en particulier sa couche d'intégration avec React. Apollo propose en effet de nombreuses fonctionnalités très pratiques pour le développeur, comme les fetch policies ou l'optimistic UI, fonctionnalités qu'il serait très intéressant d'avoir dans cozy-client
. Hors, Apollo n'expose pas (ou plus) du tout sa couche redux
. C'est ce qui fait que son API reste simple à utiliser.
Ce que l'on vise donc avec cozy-client
est une lib exposant seulement 2 pièces :
- un client, proche de
cozy-client-js
mais avec une API repensée, - un HOC de "binding" de données cozy avec des composants React.
On notera également qu'il existe des similitudes entre la raison d'être de GraphQL, à savoir un langage de requête sur n'importe quelle API côté serveur, et nos besoins à nous, développant sur la stack cozy : en effet, pour afficher une page, nous avons en général besoin de requêter différents types de données depuis la stack. Par exemple, la liste des albums de Photos nécessite de fetcher :
- les albums
- les albums partagés
- les fichiers référencés par les albums
Comme nous venons de le voir, actuellement nous passons à cozyConnect
un objet dont les propriétés sont des actions dont le dispatch va entrainer le fetch des données correspondantes :
const mapDocumentsToProps = ownProps => ({
albums: fetchAlbums(),
sharings: fetchSharedAlbums()
})
export default cozyConnect(mapDocumentsToProps)(AlbumsView)
Mais ces actions de fetch forment un tout indissociable : ce sont des fragments d'une seule et même requête (pour reprendre des termes GraphQL). Donc au final, cet objet passé à cozyConnect
est une forme de description de requête, une QueryDefinition
;) Que l'on pourrait exprimer de façon plus élégante, par exemple :
const mapDocumentsToProps = ownProps => ({
albums: all('io.cozy.albums').include([ 'files', 'shared'])
})
export default cozyConnect(mapDocumentsToProps)(AlbumsView)
Ce que l'on souhaite obtenir au final (et qui est esquissé ci-dessus), c'est un DSL générant des définitions de "requêtes cozy", et que ces QueryDefinition
ne soient plus des actions redux (car manipuler des actions et action creators redux comme on le fait pour l'instant, ça sent un peu la leaky abstraction), mais des objets décrivant à cozy-client
les données qu'il doit fetcher pour répondre à la requête. On doit donc introduire un nouvel élément dont le rôle sera :
- d'interpréter ces définitions de requêtes,
- d'appeller des méthodes du/des clients pour fetcher les données (plutôt que de les inclure dans les actions, ce qui impose d'avoir un middleware et met trop de responsabilités sur le client, en particulier de devoir gérer 2 sources de données distincts),
- de dispatcher des actions pour mettre à jour le store et notamment l'état des différentes requêtes (rôle du middleware actuellement),
- de s'assurer qu'on n'exécute pas pour rien plusieurs fois la même requête, et ce, sans forcer l'utilisateur à préciser un nom pour la requête comme c'est le cas actuellement pour les collections. C'est possible par une simple comparaison profonde des
QueryDefinition
, objets qui ne seront jamais très gros de toute façon.
Cet élément central pourrait s'appeller QueryManager
(comme dans Apollo ;), à moins que quelqu'un ait une meilleure idée.
Comme expliqué plus haut, cozy-client
nécessite actuellement un middleware redux spécifique : ce middleware régle de façon plutôt élégante le problème du boilerplate redux typique des actions asynchrones car il sait gérer les actions types: [<TRUC>_REQUEST, <TRUC>_SUCCESS, <TRUC_ERROR]
qu'émet cozy-client. Mais au final, cela apporte plus de contraintes qu'autre chose, notamment parce que cela impose un boilerplate de config redux particulier, mais aussi parce que cela force un cadre très rigide pour nos actions redux.
S'en débarasser serait l'occasion d'introduire ce QueryManager
, qui serait instancié et fourni en contexte par le CozyProvider
:
import { Component } from 'react'
import PropTypes from 'prop-types'
import QueryManager from './QueryManager'
export default class CozyProvider extends Component {
static propTypes = {
store: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
getState: PropTypes.func.isRequired
}),
client: PropTypes.object.isRequired,
children: PropTypes.element.isRequired
}
static childContextTypes = {
store: PropTypes.object,
client: PropTypes.object.isRequired
}
static contextTypes = {
store: PropTypes.object
}
constructor(props) {
super(props)
this.queryManager = new QueryManager(props.client, props.store)
}
getChildContext() {
return {
store: this.props.store || this.context.store,
client: this.props.client,
queryManager: this.queryManager
}
}
render() {
return (this.props.children && this.props.children[0]) || null
}
}
Dans un premier temps, le QueryManager
se contente donc de remplir le rôle du middleware, et devra être appellé par cozyConnect
pour dispatcher sous forme thunk les actions de fetch de cozy-client.
Aujourd'hui, lorsqu'on utilise connect
ainsi :
export default cozyConnect(ownProps => ({
albums: fetchAlbums(),
sharings: fetchSharedAlbums()
}))(AlbumsView)
On récupère 2 props dans AlbumsView
, albums
et sharings
, avec chacun un fetchStatus
, qui de surcroit change d'état à des moments différents, d'où des rerenders et flashes d'UI. La raison en est que connect
se contente de dispatcher les actions de fetch retournées par fetchAlbums
et fetchSharedAlbums
.
Il nous faut désormais considérer l'objet retourné en argument de connect
comme un objet représentant une seule et même query
, certes composée de plusieurs fetches car c'est le lot de toute API REST.
L'évolution souhaitée ici est donc de modifier le QueryManager
afin qu'il gère l'exécution des différents fetches dans le contexte d'une requête dont il persiste le statut dans le store via un nouveau reducer (ou pas : dans un premier temps on pourrait se contenter de déterminer le status de la requête à partir des fetchStatus
des différents fetches). connect
devra être modifié afin qu'une unique prop data
soit injectée, de la forme :
data: {
albums: [...],
sharings: [...],
loading: false,
error: null,
refetch() { ... },
fetchMore() { ... }
}
Dans Drive ou Photos, il peut arriver (par exemple lorsqu'on accède au contenu d'un album via une URL) que les mêmes fetches soient dispatchées plusieurs fois. L'idée ici est donc de faire en sorte que le QueryManager
génère une ID pour chaque requête et stocke dans le store l'ID ainsi que la définition de la requête, à savoir l'objet pour l'instant composé de plusieurs actions de fetches. Lorsqu'on exécute une requête, on fait une comparaison profonde de sa définition avec celles présentes dans le store, et si la requête est déjà présente, on en utilise l'ID et on se "branche" sur le résultat de cette requête.
Pour l'instant, les actions de "C(R)UD" dont le composant a besoin doivent être passées en 2ème argument de cozyConnect
, via un mapDispatchToProps
typique de redux
:
const mapDocumentsToProps = ownProps => ({
album: fetchAlbum(ownProps.router.params.albumId)
})
export const mapDispatchToProps = (dispatch, ownProps) => ({
updateAlbum: album => dispatch(updateAlbum(album))
})
export default cozyConnect(mapDocumentsToProps, mapDispatchToProps)(AlbumPhotos)
Là encore, l'abstraction fuit, et on pourrait s'en passer étant donné qu'on peut facilement déterminer à partir de la requête les actions possibles sur les résultats de celle-ci.
L'idée est donc de modifier cozyConnect
de telle façon qu'il injecte plus d'actions à l'image du fetchMore
:
data: {
albums: [...],
sharings: [...],
loading: false,
error: null,
refetch(),
fetchMore(),
create(<properties>),
update(<id>, <properties>),
destroy(<id>),
shareWith(<document>, <recipients>),
shareByLink(<document>),
revokeFor(<document>, <recipients>),
revokeLink(<document>),
...
}
Notez bien que les actions à injecter diffèrent en fonction de la requête (et de ses fragments) : ci-dessus on injecte shareWith
, shareByLink
, etc... car le fetch des albums partagés fait partie de la requête.
Si l'on fetche un simple album et ses fichiers référencés, les actions injectées sont différentes (et plus adaptées) :
data: {
album: <document>,
photos: [...] // les fichiers référencés,
loading: false,
error: null,
refetch(),
fetchMore(),
update(<properties>), // pas besoin d'ID, cette action est pré-bindée sur l'album fetché
destroy(),
addFile(<file>), // actions sur les fichiers référencés
removeFile(<file>),
...
}
Actuellement, nous avons des reducers spécifiques pour les différents types de fetches : documents, sharings, fichiers référencés... Nous avons également un grand nombre d'actions différentes, toutes du type types: [<TRUC>_REQUEST, <TRUC>_SUCCESS, <TRUC>_ERROR]
. Il y a clairement un motif autour des requêtes et des mises à jour asynchrones, à savoir que l'on émet avant une action pour "flagger" le fetch à venir dans le store, qu'en cas de succès, on émet une action dont la charge utile permet de mettre à jour le store, et qu'en cas d'échec on émet une action pour "flagger" le fetch comme étant en erreur. Il conviendrait donc de factoriser quelque part ce motif plutôt que d'avoir des actions aussi verbeuses.
D'autre part, et comme nous l'avons vu plus haut, l'objectif "ultime" est de ne plus exposer de créateurs d'actions redux, mais un DSL de création de définitions de requêtes, définitions qui seront passées en argument de cozyConnect
. Ben c'est le moment ! ;)
L'idée est d'employer une abstraction inspirée de GraphQL ; en GraphQL, il n'y a en effet que 2 types d'opérations : les Query
pour récupérer des données et les Mutation
pour les mettre à jour. Nous allons donc remplacer nos action creators par des QueryDefinition
creators et des MutationDefinition
creators : on considérera désormais qu'une définition de requête passée en argument de cozyConnect
est composée de N fragments correspondants aux différents fetches nécessaires pour répondre à la requête (ex. pour un album : fetch du document album, fetch des albums partagés, fetch des fichiers références par l'album). Nos action creators deviendraient ainsi quelque chose dans ce genre :
export const FragmentTypes = {
DOCUMENT: 'DOCUMENT',
COLLECTION: 'COLLECTION',
REFERENCED_FILES: 'REFERENCED_FILES'
}
export const fetchCollection = (doctype, options = {}, skip = 0) => ({
fragmentType: FragmentTypes.COLLECTION,
doctype,
...options,
skip,
promise: client => client.getCollection(doctype).find(options, skip)
})
export const fetchReferencedFiles = (doc, skip = 0) => ({
fragmentType: FragmentTypes.REFERENCED_FILES,
referencedBy: doc,
skip,
promise: client =>
client.getCollection('io.cozy.files').findReferencedBy(doc, skip)
})
export const fetchDocument = (doctype, id) => ({
fragmentType: FragmentTypes.DOCUMENT,
doctype,
id,
promise: client => client.getCollection(doctype).get(id)
})
// etc...
export const MutationTypes = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DESTROY: 'DESTROY',
SHARE: 'SHARE'
// etc...
}
export const createDocument = (doctype, properties) => ({
mutationType: MutationTypes.CREATE,
doctype,
properties,
promise: client => client.getCollection(doctype).create(properties)
})
// etc...
Et c'est donc le QueryManager
qui va désormais dispatcher des actions génériques pour mettre à jour le store quand il exécute une query ou une mutation. En pseudocode, cela donnerait :
class QueryManager {
// ...
query(queryId, queryDefinition) {
// note: le queryId est généré ailleurs
this.store.dispatch({ type: INIT_QUERY, queryId })
return Promise.all(
Object.keys(queryDefinition).map(fragmentName => {
const fragment = queryDefinition[fragmentName]
const fragmentId = `${queryId}#${fragmentName}`
this.store.dispatch({
type: FETCH_QUERY_FRAGMENT,
fragmentId,
...fragment
})
return = fragment
.promise(this.client)
.then(response => {
this.store.dispatch({
type: RECEIVE_QUERY_FRAGMENT,
fragmentId,
response
})
})
.catch(error => {
this.store.dispatch({
type: RECEIVE_QUERY_ERROR,
fragmentId,
error
})
})
})
).then(() => this.store.dispatch({ type: RECEIVE_QUERY_RESULT, queryId }))
.catch(() => this.store.dispatch({ type: RECEIVE_QUERY_ERROR, queryId }))
}
// ...
}
TBD
TBD
Par partie cliente "pure", j'entends le code qui parle à la stack, donc la partie non-redux. Aujourd'hui, pour fetcher des documents avec ce client, on fait :
const resp = client.fetchDocuments('io.cozy.albums')
C'est donc le client qui détermine un adapter (stack ou pouch) à utiliser pour récupérer les données, en fonction de la config du client et en particulier des éventuels doctypes définis comme étant offline. Remarquez aussi que l'API du client est vaste et un peu fourre-tout avec les doctypes particuliers comme les fichiers ou les partages (client.fetchDocuments()
, client.fetchFiles()
, client.fetchReferencedFiles()
, client.fetchSharings()
L'idée est donc d'avoir simplement 2 clients distincts : un pour la stack et un pour PouchDB, avec la même API simple :
const stackClient = new CozyStackClient(...)
const resp = stackClient.getCollection('io.cozy.albums').all()
Le client possède une méthode getCollection
qui retourne pour un doctype donné un objet avec une API générique :
all()
find(...)
create(...)
update(...)
destroy(...)
Pour la plupart des doctypes, on utilisera donc une classe Collection
générique. Pour les cas particuliers comme les fichiers ou les partages, on implémentera des classes spécifiques qui devront être register
par le client et qui possèderont la même API de base, mais aussi quelques méthodes supplémentaires spécifiques (ex: findReferenced
pour les fichiers ?)
Enfin, ce sera donc au QueryManager
désormais de savoir quel client (stack ou pouch) utiliser pour une requête en fonction de la fetchPolicy
choisie (cf ci-dessous).
Afin de gagner en efficacité, il conviendrait de pouvoir récupérer le résultat d'une requête dans le store redux si celle-ci a déjà été exécutée récemment. On pourrait aussi vouloir requêter le Pouch et si pas de résultats parce que la synchro est encore en cours, faire un fallback sur la stack. C'est l'objectif de cette option fetchPolicy
:
export default cozyConnect(ownProps => ({
operations: all('io.cozy.bank.operations')
}), {
fetchPolicy: 'cache-and-network'
})(OperationList)
Dans l'exemple ci-dessus, le QueryManager
vérifierait en premier si tous les fragments nécessaires pour répondre à la requête sont déjà dans le store, et si oui, retournerait directement le résultat de la requête, avant d'exécuter la requête via la stack et de rafraichir ensuite le résultat.
Cf https://www.apollographql.com/docs/react/basics/queries.html#graphql-config-options-fetchPolicy
Remplacer les action creators actuels (fetchCollection()
, ...) ou les queryDefinitions creators qui les auront remplacés par un DSL fonctionnel permettant de façonner les définitions de requêtes :
const query = find('io.cozy.albums').where({ name: 'foo' })
Il n'est pas idéal de dépendre encore de cozy-client-js. Il faudrait donc rapatrier tout le code nécessaire pour les fetches dans cozy-client, ce qui inclut le code d'authent.
TBD
Besoin d'un EXPLAIN pour aider l'user à comprendre pourquoi il n'a pas les données qu'il veut
TBD
TBD
TBD
Relecture @aenario : https://gist.github.com/aenario/2a67fb78daf24eb9d232b49d34cbb062#file-gistfile1-txt-L37
Relecture @enguerran : https://gist.github.com/enguerran/e4919167898e3aa611cc63d220bb7701