├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dist ├── cozy-client.js ├── cozy-client.js.map ├── cozy-client.min.js ├── cozy-client.node.js └── cozy-client.node.js.map ├── docs ├── README.md ├── _config.yml ├── auth-api.md ├── browser-sdk-transition.md ├── data-api.md ├── deprecated.md ├── files-api.md ├── intents-api.md ├── intro.md ├── jobs-api.md ├── oauth.md ├── offline.md ├── settings-api.md ├── sharing-api.md └── toc.yml ├── index.html ├── mocha-webpack.opts ├── package.json ├── src ├── auth_storage.js ├── auth_v2.js ├── auth_v3.js ├── data.js ├── doctypes.js ├── fetch.js ├── files.js ├── index.js ├── intents │ ├── client.js │ ├── helpers.js │ ├── index.js │ └── service.js ├── jobs.js ├── jsonapi.js ├── mango.js ├── offline.js ├── relations.js ├── settings.js └── utils.js ├── test ├── helpers.js ├── integration │ ├── auth.js │ ├── data.js │ ├── files.js │ ├── jobs.js │ ├── mango.js │ ├── offline.js │ ├── relations.js │ └── settings.js ├── mock-api.js ├── mock-iframe-token.js ├── package.json ├── testapp-git-daemon.sh ├── testapp-manifest.json ├── unit │ ├── auth.js │ ├── data.js │ ├── fetch.js │ ├── files.js │ ├── intents.js │ ├── jsonapi.js │ ├── mango.js │ ├── mango_utils.js │ ├── offline.js │ └── settings.js └── webapp │ └── manifest.webapp ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["cozy-app"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["cozy-app"], 3 | "rules": { 4 | "no-console": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional eslint cache 38 | .eslintcache 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # mocha-webpack tmp 47 | .tmp 48 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '16' 4 | services: 5 | - docker 6 | env: 7 | global: 8 | - GOPATH=${TRAVIS_BUILD_DIR}/_workspace 9 | - COZY_V3_DOMAIN="localhost:8080" 10 | - COZY_V3_PASSPHRASE="CozyTest_1" 11 | cache: 12 | yarn: true 13 | directories: 14 | - node_modules 15 | before_script: 16 | - docker run -d -p 5984:5984 --name couch apache/couchdb:2.3 17 | - eval "$(gimme 1.18)" 18 | - mkdir $GOPATH 19 | - git clone https://github.com/cozy/cozy-stack.git 20 | - pushd cozy-stack && make && popd 21 | - curl -X PUT http://127.0.0.1:5984/{_users,_replicator,_global_changes} 22 | - $GOPATH/bin/cozy-stack serve --host 127.0.0.1 --admin-host 127.0.0.1 & 23 | - sleep 1 24 | - $GOPATH/bin/cozy-stack instances add --passphrase "$COZY_V3_PASSPHRASE" "$COZY_V3_DOMAIN" 25 | - $GOPATH/bin/cozy-stack apps install --domain "$COZY_V3_DOMAIN" mini "file://$(pwd)/test/webapp" 26 | - export COZY_STACK_TOKEN=$($GOPATH/bin/cozy-stack instances token-app "$COZY_V3_DOMAIN" mini) 27 | - export NAME=client-js 28 | - export TOKEN=apptoken 29 | script: 30 | - yarn build 31 | deploy: 32 | provider: npm 33 | email: npm@cozycloud.cc 34 | api_key: 35 | secure: XC+urJ3qAbcdSKoWjTG3eBPx6DEJ2ddTa8nZarRBMIQmfKd5SlP2w6poD9XWm3E1Z9OxEWsksbWHWp+krCQaK/USKunDbEJrqvRDO6QIJnEdv/QAYCb8x9iHhZPADclV34hcQLLOGoIphVi3pBUmbsT1H4KvZadzJBX2DkeRMYGCvbH/If+PQmsLJm8yeF4zox2WXeRA4weIB/Lx9M7HJxTF1gre/lKjpAB7bDZhpVUQYC7m2IAgHaTFdZUlpNJQyZntCjuge+8Lckyjye64bHfMaomyqHYdn0q8Ik5vxcUuek0dGJfTtbEzoooaeG0eZ4m1nU0qPYdpJFwHsIvbsxAJrNN5w3baM/71zbCyG42bPkbYipHhtt4k9nvTaOlViLwq1Y2Il0iwXid5jjvRfQDCxEx79g+Jd8Os2DLy2G6x9XVI+gd9oLNvB3QPhMzUjSuN9gV2wNcjDSfsje/DhRaMxU6xTGajcgVy97cyhWtKKUKTVd1Bojhe0WwoCwIs4X5zsITmchjVGsnuzaguiUoTcyooMe5xYx7oYfmhALAtN/+9SWYmYPcNuSURl8OjUxI4A45hw6AXj8vqa+QU45AtQwol7+VMBS9kEfyiiVamFmq/tRM3E8ebTmifMblIOO8cEkKa93+uepVcrOX/omWc6my3JJDdDpyELdD7Y7g= 36 | on: 37 | tags: true 38 | repo: cozy/cozy-client-js 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | How to contribute to cozy-client-js? 2 | ==================================== 3 | 4 | Thank you for your interest in contributing to Cozy! There are many ways to contribute, and we appreciate all of them. 5 | 6 | 7 | Security Issues 8 | --------------- 9 | 10 | If you discover a security issue, please bring it to our attention right away! Please **DO NOT** file a public issue, instead send your report privately to security AT cozycloud DOT cc. 11 | 12 | Security reports are greatly appreciated and we will publicly thank you for it. We currently do not offer a paid security bounty program, but are not ruling it out in the future. 13 | 14 | 15 | Bug Reports 16 | ----------- 17 | 18 | While bugs are unfortunate, they're a reality in software. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. 19 | 20 | Opening an issue is as easy as following [this link][issues] and filling out the fields. Here are some things you can write about your bug: 21 | 22 | - A short summary 23 | - What did you try, step by step? 24 | - What did you expect? 25 | - What did happen instead? 26 | - What is the version of the cozy-client-js? 27 | 28 | 29 | Hack 30 | ---- 31 | 32 | This section is only if you want to modify the cozy-client-js library. If you just want to make an app, head over to [the doc](./docs/README.md). 33 | 34 | ### Install 35 | 36 | You can clone the repository and install dependencies: 37 | 38 | ```sh 39 | $ git clone https://github.com/cozy/cozy-client-js.git 40 | $ cd cozy-client-js 41 | $ npm install 42 | ``` 43 | 44 | ### Build 45 | 46 | cozy-client-js is written in ES7 and built using webpack and babel, you can run a build with `npm run build` 47 | 48 | ### Lint 49 | 50 | cozy-client-js is linted using [ESLint](https://eslint.org) and [config-eslint-cozy-app](https://www.npmjs.com/package/eslint-config-cozy-app). Lint will run when building `cozy-client-js` but you can also run it manually with `npm run lint`. 51 | 52 | ### Test 53 | 54 | Tests are written in ES7, use [assert] and run using [webpack-mocha]. 55 | 56 | They are two types of test : 57 | 58 | - In **test/unit**, we mock `fetch` and ensure each function calls the proper route(s) and parse the results as expected if the server is correct. 59 | - In **test/integration**, we run a complex scenario against both **v2** and **v3** cozy and ensure compatibility. 60 | 61 | To run integration tests, you will need one or both versions of cozy started. Have a look at the [.travis.yml](./.travis.yml) to see how it can be done. 62 | 63 | 64 | ```sh 65 | $ cd cozy-client-js 66 | $ npm run test:unit # unit tests only 67 | $ npm run test:v2 # integration tests against a node.js cozy 68 | $ npm run test:v3 # integration tests against a go cozy 69 | $ npm run test # all of the above 70 | ``` 71 | 72 | #### Run tests on local environment 73 | 74 | To run unit tests, there is nothing special, just run: 75 | 76 | ``` 77 | yarn test:unit 78 | ``` 79 | 80 | To run integration tests, you need to communicate with a [cozy-stack](https://github.com/cozy/cozy-stack) and therefore you need to get a oauth token. Run the following command and adapt commands for you own needs: 81 | 82 | ``` 83 | ./cozy-stack serve 84 | ./cozy-stack instances add --dev --passphrase "cozy" "cozy.tools:8080" 85 | ./cozy-stack instances client-oauth cozy.tools:8080 http://localhost test-app test-app 86 | ./cozy-stack instances token-oauth cozy.tools:8080 'io.cozy.files io.cozy.testobject io.cozy.testobject2 datastrings1 io.cozy.jobs io.cozy.queues' 87 | ``` 88 | 89 | It runs the cozy-stack's server, then creates a new instance with the passphrase *"cozy"* accessible on *"cozy.tools:8080"*. `client-oauth` creates a new client app and gives back an application token. Finally `token-oauth` creates an access token with permissions for all listed doctypes. 90 | 91 | When you have an access token, you can run integration tests on your local environment with command like this: 92 | 93 | ``` 94 | COZY_STACK_TOKEN= NODE_ENV=test NODE_TARGET=node COZY_STACK_VERSION=3 COZY_STACK_URL=http://cozy.tools:8080 mocha-webpack 'test/integration/**.js' 95 | ``` 96 | 97 | 98 | ### Resources 99 | 100 | All documentation is located in the `/docs` app directory. 101 | 102 | Feel free to read it and fix / update it if needed, all comments and feedback to improve it are welcome! 103 | 104 | If you add a function or change the behaviour of an existing one, doc should be updated to reflect the change. 105 | 106 | 107 | Pull Requests 108 | ------------- 109 | 110 | Please keep in mind that: 111 | 112 | - Pull-Requests point to the `development` branch 113 | - You need to cover your code and feature by tests 114 | - You may add documentation in the `/docs` directory to explain your choices if needed 115 | - We recommend to use [task lists][checkbox] to explain steps / features in your Pull-Request description 116 | - you do _not_ need to build the library to submit a PR 117 | 118 | 119 | ### Workflow 120 | 121 | Pull requests are the primary mechanism we use to change Cozy. GitHub itself has some [great documentation][pr] on using the Pull Request feature. We use the _fork and pull_ model described there. 122 | 123 | #### Step 1: Fork 124 | 125 | Fork the project on GitHub and [check out your copy locally][forking]. 126 | 127 | ``` 128 | $ git clone github.com/cozy/cozy-client-js.git 129 | $ cd cozy-client-js 130 | $ git remote add fork git://github.com/yourusername/cozy-client-js.git 131 | ``` 132 | 133 | #### Step 2: Branch 134 | 135 | Create a branch and start hacking: 136 | 137 | ``` 138 | $ git checkout -b my-branch origin/development 139 | ``` 140 | 141 | #### Step 3: Code 142 | 143 | Well, we think you know how to do that. Just be sure to follow the coding guidelines from the community ([standard JS][stdjs], comment the code, etc). 144 | 145 | #### Step 4: Test 146 | 147 | Don't forget to add tests and be sure they are green: 148 | 149 | ``` 150 | $ cd cozy-client-js 151 | $ npm run test 152 | ``` 153 | 154 | #### Step 5: Commit 155 | 156 | Writing [good commit messages][commitmsg] is important. A commit message should describe what changed and why. 157 | 158 | #### Step 6: Rebase 159 | 160 | Use `git rebase` (_not_ `git merge`) to sync your work from time to time. 161 | 162 | ``` 163 | $ git fetch origin 164 | $ git rebase origin/development my-branch 165 | ``` 166 | 167 | #### Step 7: Push 168 | 169 | ``` 170 | $ git push -u fork my-branch 171 | ``` 172 | 173 | Go to https://github.com/username/cozy-client-js and select your branch. Click the 'Pull Request' button and fill out the form. **Do not forget** to select the `development` branch as base branch. 174 | 175 | Alternatively, you can use [hub] to open the pull request from your terminal: 176 | 177 | ``` 178 | $ git pull-request -b development -m "My PR message" -o 179 | ``` 180 | 181 | Pull requests are usually reviewed within a few days. If there are comments to address, apply your changes in a separate commit and push that to your branch. Post a comment in the pull request afterwards; GitHub doesn't send out notifications when you add commits. 182 | 183 | 184 | Writing documentation 185 | --------------------- 186 | 187 | Documentation improvements are very welcome. We try to keep a good documentation in the `/docs` folder. But, you know, we are developers, we can forget to document important stuff that look obvious to us. And documentation can always be improved. 188 | 189 | 190 | Community 191 | --------- 192 | 193 | You can help us by making our community even more vibrant. For example, you can write a blog post, take some videos, answer the questions on [the forum][forum], organize new meetups, and speak about what you like in Cozy! 194 | 195 | 196 | 197 | [issues]: https://github.com/cozy/cozy-client-js/issues/new 198 | [pr]: https://help.github.com/categories/collaborating-with-issues-and-pull-requests/ 199 | [forking]: http://blog.campoy.cat/2014/03/github-and-go-forking-pull-requests-and.html 200 | [stdjs]: http://standardjs.com/ 201 | [commitmsg]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 202 | [localization]: https://github.com/cozy/cozy-client-js/blob/master/README.md#localization 203 | [hub]: https://hub.github.com/ 204 | [forum]: https://forum.cozy.io/ 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Cozy.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis build status shield](https://img.shields.io/travis/cozy/cozy-client-js/master.svg)](https://travis-ci.org/cozy/cozy-client-js) 2 | [![NPM release version shield](https://img.shields.io/npm/v/cozy-client-js.svg)](https://www.npmjs.com/package/cozy-client-js) 3 | [![NPM Licence shield](https://img.shields.io/npm/l/cozy-client-js.svg)](https://github.com/cozy/cozy-client-js/blob/master/LICENSE) 4 | 5 | ⚠️ This is the documentation for the old version of cozy-client. [Go to the new version](http://github.com/cozy/cozy-client) ✅. 6 | 7 | # [Cozy][cozy] Javascript Client 8 | 9 | ## What's Cozy? 10 | 11 | ![Cozy Logo](https://cdn.rawgit.com/cozy/cozy-guidelines/master/templates/cozy_logo_small.svg) 12 | 13 | [Cozy][cozy] is a platform that brings all your web services in the same private space. With it, your webapps and your devices can share data easily, providing you with a new experience. You can install Cozy on your own hardware where no one's tracking you. 14 | 15 | ## What's cozy-client-js? 16 | 17 | `cozy-client-js` is a javascript library made by Cozy. It enables applications (client-side apps, konnectors, OAuth apps, etc.) to make requests to the cozy stack. 18 | 19 | If you are getting started on cozy application development, you should follow this [tutorial](https://docs.cozy.io/en/dev/app/). [The reference documentation](https://docs.cozy.io/en/cozy-client-js/README/) is the place to see what you can do with this lib, and how to do it! 20 | 21 | ## Contribute 22 | 23 | If you want to work on cozy-client-js itself and submit code modifications, feel free to open pull-requests! See the [contributing guide][contribute] for more information about this repository structure, testing, linting and how to properly open pull-requests. 24 | 25 | ## Community 26 | 27 | ### Get in touch 28 | 29 | You can reach the Cozy Community by: 30 | 31 | - Chatting with us on IRC [#cozycloud on Libera.Chat][libera] 32 | - Posting on our [Forum][forum] 33 | - Posting issues on the [Github repos][github] 34 | - Say Hi! on [Twitter][twitter] 35 | 36 | ## Licence 37 | 38 | cozy-client-js is developed by Cozy Cloud and distributed under the [MIT][mit]. 39 | 40 | [cozy]: https://cozy.io 'Cozy Cloud' 41 | [doctypes]: https://cozy.github.io/cozy-doctypes/ 42 | [mit]: https://opensource.org/licenses/MIT 43 | [contribute]: CONTRIBUTING.md 44 | [libera]: https://web.libera.chat/#cozycloud 45 | [forum]: https://forum.cozy.io/ 46 | [github]: https://github.com/cozy/ 47 | [twitter]: https://twitter.com/cozycloud 48 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | [Cozy](https://cozy.io) Javascript Client 2 | ========================================= 3 | 4 | `cozy-client-js` is a javascript library made by Cozy. It enables applications (client-side apps, konnectors, OAuth apps, etc.) to make requests to the cozy stack. 5 | 6 | cozy-client-js is compatible with both cozy architectures, V2 and V3. 7 | 8 | **Cozy-client-js is still a work-in-progress. There may be some bugs, if you find any, please [open an issue](https://github.com/cozy/cozy-client-js/issues/new).** 9 | 10 | 11 | Guides 12 | ------ 13 | 14 | - [Introduction](intro.md) 15 | - [Transition from cozy-browser-sdk](browser-sdk-transition.md) 16 | - [How to support offline](offline.md) 17 | - [OAuth guide](oauth.md) 18 | 19 | 20 | Modules API 21 | ----------- 22 | 23 | - [Authentication](auth-api.md) 24 | - [Data System](data-api.md) 25 | - [Files](files-api.md) 26 | - [Jobs](jobs-api.md) 27 | - [Intents](intents-api.md) 28 | - [Settings](settings-api.md) 29 | - [Sharing](sharing-api.md) 30 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /docs/auth-api.md: -------------------------------------------------------------------------------- 1 | # Authentication and OAuth (internal) 2 | 3 | ### `cozy.client.auth.registerClient(clientParams)` 4 | 5 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 6 | 7 | `cozy.client.auth.registerClient` is used to register a new client with the specified informations. 8 | 9 | It returns a promise of the newly registered Client, along with a client secret and identifier. 10 | 11 | - `clientParams` are client parameters: a non registered instance of `cozy.client.auth.Client` 12 | 13 | ```js 14 | const clientParams = new cozy.client.auth.Client({ 15 | redirectURI: 'http://localhost:3000/', 16 | softwareID: 'mysoftware', 17 | clientName: 'Great mobile App' 18 | }) 19 | const client = await cozy.client.auth.registerClient(clientParams) 20 | const clientID = client.clientID 21 | const clientSecret = client.clientSecret 22 | ``` 23 | 24 | ### `cozy.client.auth.updateClient(client, resetSecret = false)` 25 | 26 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 27 | 28 | `cozy.client.auth.updateClient` is used to update informations about the oauth client. 29 | 30 | It returns a promise for the updated Client. 31 | 32 | - `client` a registered instance of `cozy.client.auth.Client` 33 | - `resetSecret` by setting `resetSecret` to `true`, a new Secret is generated. 34 | 35 | ```js 36 | const client = await cozy.client.auth.registerClient(clientParams) 37 | 38 | // change the client's version 39 | client.softwareVersion = "v1.2.3" 40 | const client = await cozy.client.auth.updateClient(client) 41 | 42 | 43 | // change the client secret 44 | const client = await cozy.client.auth.updateClient(client, true) 45 | ``` 46 | 47 | ### `cozy.client.auth.unregisterClient(client)` 48 | 49 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 50 | 51 | `cozy.client.auth.unregisterClient` is used to unregister a client. 52 | 53 | It returns a promise for completion 54 | 55 | - `client` a registered instance of `cozy.client.auth.Client` 56 | 57 | ```js 58 | const client = await cozy.client.auth.registerClient(clientParams) 59 | await cozy.auth.client.unregisterClient(client) 60 | ``` 61 | 62 | ### `cozy.auth.client.getClient(client)` 63 | 64 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 65 | 66 | `cozy.client.auth.getClient` is used to fetch a registered client with the specified clientID and token. 67 | 68 | It returns a promise of the client returned by the server. 69 | 70 | - `client` is a registered `cozy.client.auth.Client` 71 | 72 | ```js 73 | const client = await cozy.client.auth.getClient(new cozy.client.auth.Client({ 74 | clientID: '1235' 75 | })) 76 | ``` 77 | 78 | 79 | ### `cozy.client.auth.getAuthCodeURL(client, scopes)` 80 | 81 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 82 | 83 | `cozy.client.auth.getAuthCodeURL` is used to generate the URL on which the user should go to give access to the application with the specified scopes. 84 | 85 | It returns an object with the url and a generated random state that should be stored to be matched again for the token exchange phase. 86 | 87 | - `client` is a registered `cozy.client.auth.Client` 88 | - `scopes` is an array of permission strings formatted as `key:access` (like `files/images:read`) 89 | 90 | ```js 91 | const {url, state} = cozy.client.auth.getAuthCodeURL(client, ['files/images:read']) 92 | 93 | // save state and redirect to url 94 | localStorage.setItem("oauthstate", state) 95 | window.location.replace(url) 96 | ``` 97 | 98 | 99 | ### `cozy.client.auth.getAccessToken(client, state, pageURL)` 100 | 101 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 102 | 103 | `cozy.client.auth.getAccessToken` is used from the page on which the user should have redirected after authorizing the application. 104 | 105 | It returns a promise of an `cozy.client.auth.AccessToken`. The method verifies that the specified state and the extracted one match. It then ask the server for a new access token and returns it. 106 | 107 | - `client` is a registered `cozy.client.auth.Client` 108 | - `state` is the previously stored state that is matched against to prevent CSRF attacks 109 | - `pageURL` is the url of the current page from the which a code and state will be extracted. If empty, `window.location.href` is used 110 | 111 | ```js 112 | const client = cozy.client.auth.getClient(/* ... */) 113 | const state = localStorage.getItem("oauthstate") 114 | const pageURL = window.location.href 115 | const token = cozy.client.auth.getAccessToken(client, state, pageURL) 116 | ``` 117 | 118 | 119 | ### `cozy.client.auth.refreshToken` 120 | 121 | **This method is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 122 | 123 | `cozy.client.auth.refreshToken` is used to refresh a token that is expired or no more valid. 124 | 125 | - `client` is a registered `cozy.client.auth.Client` 126 | - `token` is a valid `cozy.client.auth.AccessToken` 127 | 128 | ```js 129 | const newtoken = cozy.client.auth.refreshToken(client, oldtoken) 130 | ``` 131 | 132 | 133 | ### `cozy.client.auth.Client` 134 | 135 | **This class is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 136 | 137 | `cozy.client.auth.Client` is a class representing an OAuth client. It can be registered, in which case it is known by the server and has a `clientID` and `clientSecret`. 138 | 139 | ``` 140 | type Client { 141 | clientID: string; // informed by server 142 | clientSecret: string; // informed by server 143 | registrationAccessToken: string; // informed by server 144 | redirectURI: string; // mandatory 145 | softwareID: string; // mandatory 146 | softwareVersion: string; 147 | clientName: string; // mandatory 148 | clientKind: string; 149 | clientURI: string; 150 | logoURI: string; 151 | policyURI: string; 152 | notificationPlatform: string; 153 | notificationDeviceToken: string; 154 | } 155 | ``` 156 | 157 | The constructor type is as follow: 158 | 159 | - `url` is a string of the url of the cozy 160 | - `options` is an object with the same fields as a client object, or is an instance of client 161 | 162 | ``` 163 | new Client(url, options) 164 | ``` 165 | 166 | 167 | ### `cozy.client.auth.AccessToken` 168 | 169 | **This class is for internal or advanced usages. Please see [OAuth document](./oauth.md) to see how to use OAuth with this library** 170 | 171 | `cozy.client.auth.AccessToken` is a class representing an OAuth access token. 172 | 173 | ``` 174 | type Token { 175 | tokenType: string; 176 | accessToken: string; 177 | refreshToken: string; 178 | scope: string 179 | } 180 | ``` 181 | 182 | The constructor takes an object with the same fields as a Token object. 183 | 184 | -------------------------------------------------------------------------------- /docs/browser-sdk-transition.md: -------------------------------------------------------------------------------- 1 | # How to transition your app from cozy-browser-sdk to cozy-client-js 2 | 3 | ## Full doctype qualification 4 | 5 | Cozy **v3** expects [doctypes](https://cozy.github.io/cozy-doctypes/) to be qualified to ensure uniqueness. 6 | 7 | All doctypes designed by the cozy's team will be prefixed with `io.cozy.` 8 | 9 | Any doctype you introduce is expected to be prefixed with the reverse notation of a domain you own (JLS#7.7). 10 | 11 | You are free to reuse another applications documents by using their doctypes but you SHOULD discuss with the domain owner if you want to add fields or format them differently. 12 | 13 | To ensure retrocompatibility, when used on stack v2, all known doctypes will be auto-prefixed by `io.cozy.` or `io.cozy.labs.`, but a warning will be written to the console. Unknown doctype will cause a fatal error. DO NOT rely on this behaviour in new applications, use qualified doctypes. 14 | 15 | 16 | ```javascript 17 | // old version, using cozy-browser-sdk 18 | cozysdk.create("Contact", {}) 19 | cozysdk.create("Book", {}) 20 | // new version, with cozy-client-js 21 | cozy.data.create("io.cozy.contacts", {}) 22 | cozy.data.create("com.mydomain.book", {}) 23 | ``` 24 | 25 | ## MapReduce Views vs Mango queries 26 | 27 | Cozy **v3** recommends using Couchdb 2 indexes & mango queries instead of Couchdb 1.X map-reduce views. We feel they are [simpler to understand and explain](http://cozy.github.io/cozy-browser-sdk/tutorial-mapreduce.html) and avoid useless overindexing. 28 | 29 | When used on a **v2** cozy, the `defineIndex` and `query` calls will be translated to MapReduce views. 30 | 31 | If you need the full power of MapReduce, please open a issue on cozy-stack with your usecase. 32 | 33 | ```javascript 34 | // cozy-browser-sdk 35 | cozysdk.defineMapReduceView('Event', 'all', function(doc) { emit(doc.year); }) 36 | cozysdk.queryView('Event', 'all', {key: 2016, limit: 10}) 37 | // cozy-client-js 38 | index = cozy.data.defineIndex('Event', ['year']) 39 | cozy.data.query(index, {selector: {year: 2016}, limit: 10}) 40 | 41 | ``` 42 | 43 | ## Binaries 44 | 45 | We do not yet have a plan for binaries attachments to documents. 46 | They will be probably placed in the VFS under a special path. 47 | 48 | ## Crud is fully compatible 49 | 50 | The following functions have the same signature than the cozy-browser-sdk 51 | ```javascript 52 | created = await cozy.data.create(myType, book) 53 | doc = await cozy.data.find(myType, id) 54 | doc2 = await cozy.data.updateAttributes(myType, id, {year: 1851}) 55 | await cozy.data.destroy("my.domain.book", id) 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/data-api.md: -------------------------------------------------------------------------------- 1 | # Data API 2 | 3 | ### `cozy.client.data.create(doctype, attributes)` 4 | 5 | `cozy.client.data.create(doctype, attributes)` adds a document to the database. 6 | 7 | It returns a promise for the created object. The created object has the same attributes than thoses passed, with an added `_id`. It's the unique identifier for the created document. 8 | 9 | If you use an existing doctype, you should follow its expected format. **v2** does not enforce this, but we plan to on **v3**. Anyway, do you want to be the app that creates empty contacts in the native app ? 10 | 11 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 12 | - `attributes` is an object that can be stringified using `JSON.stringify`: if it is a complex or cyclic object, add a `toJSON` method to it (native behaviour of `JSON.stringify`). 13 | 14 | **Warning**: on **v3**, an extra field `_rev` is added, it is the unique identifier for the document revision, after creation, it will be of the shape `1-xxxxxxxxx` for first revision. 15 | 16 | ```javascript 17 | // simple object 18 | const book = { title: "Moby Dick", author:"Herman Melville", isbn: "42" } 19 | const created = await cozy.client.data.create(myBooksDoctype, book) 20 | // same fields 21 | console.log(created.title, created.author, created.isbn) 22 | // _id, _rev are added 23 | console.log(created._id, created._rev) 24 | // let's keep it for later 25 | createdBookId = created._id 26 | ``` 27 | 28 | 29 | ### `cozy.client.data.find(doctype, id)` 30 | 31 | `cozy.client.data.find(doctype, id)` returns the document associated to the given ID. 32 | 33 | It returns a promise for the document. It will have the same fields than the returned value of `create`, including `_id` and `_rev`. 34 | 35 | If the document does not exist, the promise will be rejected or the callback will be passed an error. 36 | 37 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 38 | - `id` is a string specifying the identifier of the document you look for 39 | 40 | ```javascript 41 | const doc = await cozy.client.data.find(myBooksDoctype, createdBookId) 42 | console.log(doc._id, doc._rev, doc.title, doc.author, doc.isbn) 43 | ``` 44 | 45 | 46 | ### `cozy.client.data.findMany(doctype, [ids])` 47 | 48 | `cozy.client.data.findMany(doctype, [ids])` returns the documents associated to the given IDs. 49 | 50 | It returns a promise for a map, with the given IDs as keys. 51 | 52 | When a document is found, the corresponding value will be an object with a 53 | single `doc` key containing the document. 54 | Documents will have the same fields than the returned values of `create`, 55 | including `_id` and `_rev`. 56 | 57 | When a document is missing, the corresponding value will be an object with a 58 | single `error` key containing the error message. 59 | Even when some document is missing, the promise will still be fulfilled or the 60 | callback will be passed the resulting map, so you can still access the other 61 | documents found and identify the missing ones. 62 | 63 | - `doctype` is a string specifying the 64 | [doctype](intro.md#doctypes--permissions) 65 | - `ids` is an array of strings specifying the identifiers of the documents you 66 | look for 67 | 68 | **Warning**: Not available on **v2**. A fallback implementation could be 69 | provided at some point. 70 | 71 | ```javascript 72 | const ids = [createdBookId, ...] 73 | const resultsById = await cozy.client.data.findMany(myBooksDoctype, ids) 74 | for (const id of ids) { 75 | const {doc, error} = resultsById[id] 76 | if (error) { 77 | console.error(`Error while fetching book ${id}: ${error}`) 78 | } else { 79 | console.log(doc._id, doc._rev, doc.title, doc.author, doc.isbn) 80 | } 81 | } 82 | ``` 83 | 84 | ### `cozy.client.data.findAll(doctype)` 85 | 86 | `cozy.client.data.findAll(doctype)` returns the all documents associated to the given doctype. 87 | 88 | It returns a promise for a map, with the given IDs as keys. 89 | 90 | When a document is found, the corresponding value will be an object with a 91 | single `doc` key containing the document. 92 | Documents will have the same fields than the returned values of `create`, 93 | including `_id` and `_rev`. 94 | 95 | - `doctype` is a string specifying the 96 | [doctype](intro.md#doctypes--permissions) 97 | look for 98 | 99 | **Warning**: Not available on **v2**. A fallback implementation could be 100 | provided at some point. 101 | 102 | ```javascript 103 | const result = await cozy.client.data.findAll(myBooksDoctype) 104 | if (result.error) { 105 | console.error('Error while fetching books') 106 | } 107 | for (const id of result.keys) { 108 | const doc = result.docs[id] 109 | console.log(doc._id, doc._rev, doc.title, doc.author, doc.isbn) 110 | } 111 | ``` 112 | 113 | 114 | ### `cozy.client.data.changesFeed(doctype, options)` 115 | 116 | `cozy.client.data.changesFeed(doctype, options)` returns the last changes from CouchDB for the given doctype 117 | 118 | It returns a promise for the changes. 119 | 120 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 121 | - `options` is an object, only its `since` parameter is supported currently 122 | 123 | ```javascript 124 | const changes = await.cozy.client.data.changesFeed(myBooksDoctype, { since: 0 }) 125 | console.log(changes.last_seq, changes.results) 126 | ``` 127 | 128 | 129 | ### `cozy.client.data.update(doctype, doc, newdoc)` 130 | 131 | `cozy.client.data.update(doctype, doc, newdoc)` replaces the document by a new version. 132 | 133 | It returns a promise for the updated document. The updated document will have the same fields and values than provided in newdoc, the same `_id` than doc, and a `_rev` incremented from doc's number. 134 | 135 | If the document does not exist, the promise will reject with an error. 136 | 137 | If the document current `_rev` does not match the passed one, it means there is a conflict and the promise is rejected with an error. 138 | 139 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 140 | - `doc` is an object with *at least* the fields `_id` and `_rev` containing the identifier and revision of the file you want to update. 141 | - `newdoc` is an object, specifying the new content of the document 142 | 143 | ```javascript 144 | const updates = { title: "Moby Dick !", author:"THE Herman Melville"} 145 | const updated = await cozy.client.data.update(myBooksDoctype, doc, updates) 146 | console.log(updated._id === doc._id) // _id does not change 147 | console.log(updated._rev) // 2-xxxxxx 148 | console.log(updated.title, updated.year) // fields are changed 149 | console.log(updated.isbn === undefined) // update erase fields 150 | ``` 151 | 152 | 153 | ### `cozy.client.data.updateAttributes(doctype, id, changes)` 154 | 155 | `cozy.client.data.updateAttributes(doctype, id, changes)` applies change to the document. 156 | 157 | It returns a promise for the updated document. The updated document will be the result of merging changes into the document with given `_id` and a incremented `_rev`. 158 | 159 | If the document does not exist, the promise will be rejected or the callback will be passed an error. 160 | 161 | This function gives 3 attempts not to conflict. 162 | 163 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 164 | - `id` is a string specifying the identifier of the document to update 165 | - `changes` is an object 166 | 167 | ```javascript 168 | const updates = { year: 1852} 169 | const updated = await cozy.client.data.updateAttributes(myBooksDoctype, id, updates) 170 | console.log(updated._id === doc._id) // _id does not change 171 | console.log(updated._rev) // 3-xxxxxx 172 | console.log(updated.year) // fields are changed 173 | console.log(updated.isbn) // updateAttributes preserve other fields 174 | ``` 175 | 176 | 177 | ### `cozy.client.data.delete(doctype, doc)` 178 | 179 | `cozy.client.data.delete(doctype, doc )` will erase the document from the database. 180 | 181 | It returns a promise which will be resolved when the document has been deleted. 182 | 183 | If the document does not exist, the promise will be rejected with an error. If the document current `_rev` does not match the passed one, it means there is a conflict and the promise is rejected with an error. 184 | 185 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 186 | - `doc` is an object with *at least* the fields `_id` and `_rev` containing the identifier and revision of the file you want to destroy. 187 | 188 | ```javascript 189 | await cozy.client.data.delete(myBooksDoctype, updated) 190 | ``` 191 | 192 | 193 | ### `cozy.client.data.defineIndex(doctype, fields)` 194 | 195 | `cozy.client.data.defineIndex(doctype, fields)` creates an index for a document type. It is idempotent, it can be called several time with no bad effect. 196 | 197 | It returns a promise for an **indexReference**, which can be passed to `cozy.data.query`. 198 | 199 | - `doctype` is a string specifying the [doctype](intro.md#doctypes--permissions) 200 | - `fields` is an array of the fields name to index 201 | 202 | **Warning**: when used on **v2**, a map-reduce view is created internally, when used on **v3**, we use couchdb built-in mango queries. 203 | 204 | ```javascript 205 | const booksByYearRef = await cozy.client.data.defineIndex(myType, ['year', 'rating']) 206 | ``` 207 | 208 | 209 | ### `cozy.client.data.query(indexReference, query)` 210 | 211 | `cozy.client.data.query(indexReference, query)` find documents using an index. 212 | 213 | It returns a promise with a list of documents matching the query. Results will be returned in the order defined for the index. 214 | 215 | - `query` is an object with the following fields: 216 | * `selector`: a mango selector 217 | * `limit`: maximum number of results 218 | * `skip`: ignore the first x results (pagination) 219 | * `wholeResponse`: when set to true, the whole query response will be returned instead of just the docs. This is useful when paginating, because you'll get the `next` property in the response object. 220 | 221 | **Warning**: complex mango queries are not compatible with **v2** 222 | 223 | ```javascript 224 | const results = await cozy.client.data.query(booksByYearRef, { 225 | "selector": {year: 1851}, 226 | "limit": 3, 227 | "skip": 1 228 | }) 229 | 230 | results.length == 3 // we limited to 3 results 231 | resuts[0]._id === doc._id 232 | resuts[0].title === "Moby Dick" 233 | resuts[0].rating < 2 // lowest rating first 234 | ``` 235 | -------------------------------------------------------------------------------- /docs/deprecated.md: -------------------------------------------------------------------------------- 1 | ⚠️ cozy-client-js is a deprecated library. We recommend to use our new client : [Cozy Client](https://docs.cozy.io/en/cozy-client/getting-started/) 2 | 3 | If you really need to see the documentation of cozy-client-js, see it on [github](https://github.com/cozy/cozy-client-js/blob/master/docs/README.md) 4 | -------------------------------------------------------------------------------- /docs/intents-api.md: -------------------------------------------------------------------------------- 1 | # Intents 2 | 3 | ### `cozy.client.intents.create()` 4 | 5 | `cozy.client.intents.create(action, doctype [, data, permissions])` create an intent. It returns a modified Promise for the intent document, having a custom `start(element)` method. This method interacts with the DOM to append an iframe to the given HTML element. This iframe will provide an access to an app, which will serve a service page able to manage the intent action for the intent doctype. The `start(element)` method returns a promise for the result document provided by intent service. 6 | 7 | > __On Intent ready callback:__ This `start` method also takes a second optional argument which is a callback function (`start(element, onReadyCallback)`). When provided, this function will be run when the intent iframe will be completely loaded (using the `onload` iframe listener). This callback could be useful to run a client code only when the intent iframe is ready and loaded. 8 | 9 | An intent has to be created everytime an app need to perform an action over a doctype for wich it does not have permission. For example, the Cozy Drive app should create an intent to `pick` a `io.cozy.contacts` document. The cozy-stack will determines which app can offer a service to resolve the intent. It's this service's URL that will be passed to the iframe `src` property. 10 | 11 | Once the intent process is terminated by service, the iframe is removed from DOM. 12 | 13 | #### Example 14 | ```js 15 | cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100, height: 100}) 16 | .start(document.getElementById('intent-service-wrapper')) 17 | ``` 18 | 19 | See cozy-stack [documentation](https://docs.cozy.io/en/cozy-stack/intents/) for more details. 20 | 21 | You can also use `.then` to run some code after the intents is terminated like following: 22 | 23 | ```js 24 | cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100, height: 100}) 25 | .start(document.getElementById('intent-service-wrapper')) 26 | .then(doc => { // after service.terminate(doc) 27 | // code to use the doc 28 | }) 29 | ``` 30 | 31 | Example to use `removeIntentFrame()` method (by passing the flag `exposeIntentFrameRemoval` flag): 32 | ```js 33 | cozy.client.intents.create('EDIT', 'io.cozy.photos', {action: 'crop', width: 100, height: 100, exposeIntentFrameRemoval: true}) 34 | .start(document.getElementById('intent-service-wrapper')) 35 | .then({removeIntentFrame, doc} => { // after service.terminate(doc) 36 | // Code to be run before removing the terminated intent iframe 37 | removeIntentFrame() 38 | // Other code, use doc 39 | }) 40 | ``` 41 | 42 | ### `cozy.client.intents.createService()` 43 | 44 | `cozy.client.intents.createService([intentId, window])` has to be used in the intent service page. It initializes communication with the parent window (remember: the service is supposed to be in an iframe). 45 | 46 | If `intentId` and `window` parameters are not provided the method will try to retrieve them automatically. 47 | 48 | It returns a *service* object, which provides the following methods : 49 | * `compose(action, doctype, data)`: request the client to make a second intent. This returns a promise fulfilled with the second intent result. 50 | ```js 51 | // ... 52 | const app = await service.compose('INSTALL', 'io.cozy.apps', { slug: 'drive' }) 53 | ``` 54 | * `getData()`: returns the data passed to the service by the client. 55 | * `getIntent()`: returns the intent 56 | * `resizeClient(doc, transitionProperty)`: forces the size of the intent modale to a given width, maxWidth, height, maxHeight, or dimensions of a given element. The second optional argument `transitionProperty` can be used to add a CSS transition property on the intent in order to 'animate' the resizing. 57 | ```js 58 | // resize the client ot 300 pixels max height 59 | service.resizeClient({ 60 | maxHeight: 300 61 | }, '.2s linear') // will be in css -> transition: .2s linear; 62 | // or 63 | service.resizeClient({ 64 | element: document.querySelector('.class') 65 | }) 66 | ``` 67 | 68 | > __On intent size:__ If an intent is used by multiple applications, we don't use resizeClient(), since each application can have his own layout. You have to define the size of the intent in your application 69 | 70 | 71 | * `terminate(doc)`: ends the intent process by passing to the client the resulting document `doc`. An intent service may only be terminated once. 72 | > If a boolean `exposeIntentFrameRemoval` is found as `true` in the data sent by the client, the `terminate()` method will return an object with as properties a function named `removeIntentFrame` to remove the iframe DOM node (in order to be run by the client later on) and the resulting document `doc`. This could be useful to animate an intent closing and remove the iframe node at the animation ending. 73 | 74 | * `cancel()`: ends the intent process by passing a `null` value to the client. This method terminate the intent service the same way that `terminate()`. 75 | * `throw(error)`: throw an error to client and causes the intent promise rejection. 76 | 77 | #### Example 78 | ```js 79 | cozy.client.intents.createService('77bcc42c-0fd8-11e7-ac95-8f605f6e8338', window) 80 | .then(intentService => { 81 | const data = intentService.getData() 82 | 83 | // [...] 84 | // Do stuff with data 85 | // [...] 86 | 87 | const resultingDoc = { 88 | type: 'io.cozy.photos', 89 | width: 100, 90 | height: 100 91 | } 92 | 93 | intentService.terminate(resultingDoc) 94 | }) 95 | ``` 96 | 97 | ### `cozy.client.intents.getRedirectionURL()` 98 | 99 | `cozy.client.intents.getRedirectionURL(doctype, data)` retrieves a redirection URL for a given doctype, with specified data. It relies internally on a regular intent mechanism, which creates an intent for the `REDIRECT` action. It then build the redirection URL from URL sent by the stack and returns it. This URL can be used as link `href` for example, to show the doctype or the document in an application able to handle it. 100 | 101 | #### Example 102 | ```jsx 103 | const myFolder = { 104 | folder: '4bce4649-e7b7-4226-d82e-6b87dbb684e7' 105 | } 106 | 107 | const url = await cozy.client.getRedirectionURL('io.cozy.files', myFolder) 108 | // url is http://domain-app.cozy.rocks/#/files?folder=4bce4649-e7b7-4226-d82e-6b87dbb684e7 109 | ``` 110 | 111 | ### `cozy.client.intents.redirect()` 112 | 113 | `cozy.client.intents.redirect(doctype, data)` is based on `cozy.client.intents.getRedirectionURL()` and it redirects the browser to the retrieved URL. 114 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction to cozy-client-js 2 | 3 | ## Reminder about cozy architectures 4 | 5 | There is two actives cozy architectures: 6 | 7 | - The first, thereafter named **v2** is the existing cozy structure. It's based on 1 container / user with many node.js processes in each container (cozy-data-system, cozy-proxy, cozy-home, 1 process per app). 8 | - The second, thereafter named **v3** is the new cozy-stack ([repository](https://github.com/cozy/cozy-stack)). It's based on go and aim to support multiple user on a single process. 9 | 10 | **v2** supported both **server-side** applications with their own node.js process and **client-side** applications which run in the user browser. 11 | 12 | **v3** will not support **server-side** applications. We will support server side modules managed by server administrator, but the applications themselves will all be **client-side** application. 13 | 14 | This repository provides a library which allows you to build a **client-side** application compatible with both version. 15 | 16 | The former javascript library for making client-side application was `cozy-browser-sdk`. We aim to deprecate it once `cozy-client-js` reaches feature-parity. 17 | 18 | If you already have an application using `cozy-browser-sdk`, you can see what will change in the [transition document](./browser-sdk-transition.md). If you have any doubt, please open an issue! 19 | 20 | 21 | ## Include the library in your application 22 | 23 | You can `import`/`require` cozy-client-js using npm & webpack. 24 | 25 | You can also copy-paste the `dist/cozy-client.js` bundle file into your application, and include it in your application `index.html` with ` 7 | 8 | 9 |

This is the testing ground for cozy-client-js.

10 |

Open a console

11 |
12 |       cozy.init({ cozyURL: 'http://cozy.tools:8080', token: "{{.Token}}" })
13 |     
14 | 15 | 16 | -------------------------------------------------------------------------------- /mocha-webpack.opts: -------------------------------------------------------------------------------- 1 | --colors 2 | --reporter spec 3 | --include babel-polyfill 4 | --require source-map-support/register 5 | --webpack-config webpack.config.js 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cozy-client-js", 3 | "version": "0.21.0", 4 | "description": "Javascript library to interact with a cozy", 5 | "main": "dist/cozy-client.node.js", 6 | "browser": "dist/cozy-client.js", 7 | "files": [ 8 | "dist", 9 | "docs" 10 | ], 11 | "scripts": { 12 | "build:web": "NODE_TARGET=web webpack", 13 | "build:web:min": "NODE_ENV=production NODE_TARGET=web webpack", 14 | "build:node": "NODE_ENV=production NODE_TARGET=node webpack", 15 | "build": "npm-run-all --parallel 'build:*' 'build:*:*'", 16 | "lint": "eslint '{src,test}/**/*.{js,jsx}'", 17 | "prettier": "prettier --write '{src,test}/**/*.{js,jsx}' && eslint --fix '{src,test}/**/*.{js,jsx}'", 18 | "watch": "NODE_ENV=development NODE_TARGET=web webpack --watch", 19 | "test": "npm-run-all 'test:*'", 20 | "test:unit": "NODE_ENV=test NODE_TARGET=node mocha-webpack 'test/unit/**.js'", 21 | "test:v3": "NODE_ENV=test NODE_TARGET=node COZY_STACK_VERSION=3 COZY_STACK_URL=http://localhost:8080 mocha-webpack 'test/integration/**.js'", 22 | "clean": "rm -r dist .tmp", 23 | "shasum": "shasum -a 256 dist/cozy-client.min.js && shasum -a 256 dist/cozy-client.min.js.map" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/cozy/cozy-client-js.git" 28 | }, 29 | "keywords": [ 30 | "cozy", 31 | "api", 32 | "v2", 33 | "v3" 34 | ], 35 | "author": "cozycloud.cc", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/cozy/cozy-client-js/issues" 39 | }, 40 | "homepage": "https://github.com/cozy/cozy-client-js", 41 | "devDependencies": { 42 | "babel-core": "^6.23.1", 43 | "babel-loader": "^6.4.0", 44 | "babel-polyfill": "^6.26.0", 45 | "babel-preset-cozy-app": "^0.3.2", 46 | "btoa": "1.2.1", 47 | "chai": "3.5.0", 48 | "eslint": "^4.19.1", 49 | "eslint-config-cozy-app": "^0.5.1", 50 | "eslint-loader": "^2.0.0", 51 | "eslint-plugin-react": "^7.7.0", 52 | "fetch-mock": "5.5.0", 53 | "mocha": "6.1.4", 54 | "mocha-webpack": "1.1.0", 55 | "npm-run-all": "^4.0.2", 56 | "pouchdb-adapter-memory": "7.0.0", 57 | "prettier": "^1.12.1", 58 | "should": "11.1.1", 59 | "sinon": "^2.1.0", 60 | "source-map-support": "0.4.5", 61 | "webpack": "3.12.0", 62 | "webpack-node-externals": "1.7.2" 63 | }, 64 | "dependencies": { 65 | "core-js": "^3.6.5", 66 | "cross-fetch": "^3.0.6", 67 | "pouchdb-browser": "7.0.0", 68 | "pouchdb-find": "7.0.0" 69 | }, 70 | "peerDependencies": { 71 | "babel-polyfill": "^6.26.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/auth_storage.js: -------------------------------------------------------------------------------- 1 | export class LocalStorage { 2 | constructor(storage, prefix) { 3 | if (!storage && typeof window !== 'undefined') { 4 | storage = window.localStorage 5 | } 6 | this.storage = storage 7 | this.prefix = prefix || 'cozy:oauth:' 8 | } 9 | 10 | save(key, value) { 11 | return new Promise(resolve => { 12 | this.storage.setItem(this.prefix + key, JSON.stringify(value)) 13 | resolve(value) 14 | }) 15 | } 16 | 17 | load(key) { 18 | return new Promise(resolve => { 19 | const item = this.storage.getItem(this.prefix + key) 20 | if (!item) { 21 | resolve() 22 | } else { 23 | resolve(JSON.parse(item)) 24 | } 25 | }) 26 | } 27 | 28 | delete(key) { 29 | return new Promise(resolve => 30 | resolve(this.storage.removeItem(this.prefix + key)) 31 | ) 32 | } 33 | 34 | clear() { 35 | return new Promise(resolve => { 36 | const storage = this.storage 37 | for (let i = 0; i < storage.length; i++) { 38 | const key = storage.key(i) 39 | if (key.indexOf(this.prefix) === 0) { 40 | storage.removeItem(key) 41 | } 42 | } 43 | resolve() 44 | }) 45 | } 46 | } 47 | 48 | export class MemoryStorage { 49 | constructor() { 50 | this.hash = Object.create(null) 51 | } 52 | 53 | save(key, value) { 54 | this.hash[key] = value 55 | return Promise.resolve(value) 56 | } 57 | 58 | load(key) { 59 | return Promise.resolve(this.hash[key]) 60 | } 61 | 62 | delete(key) { 63 | const deleted = delete this.hash[key] 64 | return Promise.resolve(deleted) 65 | } 66 | 67 | clear() { 68 | this.hash = Object.create(null) 69 | return Promise.resolve() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/auth_v2.js: -------------------------------------------------------------------------------- 1 | /* global btoa */ 2 | const V2TOKEN_ABORT_TIMEOUT = 3000 3 | 4 | export function getAppToken() { 5 | return new Promise(function(resolve, reject) { 6 | if (typeof window === 'undefined') { 7 | return reject(new Error('getV2Token should be used in browser')) 8 | } else if (!window.parent) { 9 | return reject(new Error('getV2Token should be used in iframe')) 10 | } else if (!window.parent.postMessage) { 11 | return reject(new Error('getV2Token should be used in modern browser')) 12 | } 13 | const origin = window.location.origin 14 | const intent = { action: 'getToken' } 15 | let timeout = null 16 | const receiver = function(event) { 17 | let token 18 | try { 19 | token = new AppToken({ 20 | appName: event.data.appName, 21 | token: event.data.token 22 | }) 23 | } catch (e) { 24 | reject(e) 25 | return 26 | } 27 | window.removeEventListener('message', receiver) 28 | clearTimeout(timeout) 29 | resolve({ client: null, token }) 30 | } 31 | window.addEventListener('message', receiver, false) 32 | window.parent.postMessage(intent, origin) 33 | timeout = setTimeout(() => { 34 | reject(new Error('No response from parent iframe after 3s')) 35 | }, V2TOKEN_ABORT_TIMEOUT) 36 | }) 37 | } 38 | 39 | export class AppToken { 40 | constructor(opts) { 41 | this.appName = opts.appName || '' 42 | this.token = opts.token || '' 43 | } 44 | 45 | toAuthHeader() { 46 | return 'Basic ' + btoa(`${this.appName}:${this.token}`) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/auth_v3.js: -------------------------------------------------------------------------------- 1 | /* global btoa */ 2 | import { encodeQuery, decodeQuery, isOffline } from './utils' 3 | import { cozyFetchJSON, FetchError } from './fetch' 4 | 5 | const StateSize = 16 6 | 7 | export const CredsKey = 'creds' 8 | export const StateKey = 'state' 9 | 10 | export class Client { 11 | constructor(opts) { 12 | this.clientID = opts.clientID || opts.client_id || '' 13 | this.clientSecret = opts.clientSecret || opts.client_secret || '' 14 | this.registrationAccessToken = 15 | opts.registrationAccessToken || opts.registration_access_token || '' 16 | 17 | if (opts.redirect_uris) { 18 | this.redirectURI = opts.redirect_uris[0] || '' 19 | } else { 20 | this.redirectURI = opts.redirectURI || '' 21 | } 22 | 23 | this.softwareID = opts.softwareID || opts.software_id || '' 24 | this.softwareVersion = opts.softwareVersion || opts.software_version || '' 25 | this.clientName = opts.clientName || opts.client_name || '' 26 | this.clientKind = opts.clientKind || opts.client_kind || '' 27 | this.clientURI = opts.clientURI || opts.client_uri || '' 28 | 29 | this.logoURI = opts.logoURI || opts.logo_uri || '' 30 | this.policyURI = opts.policyURI || opts.policy_uri || '' 31 | 32 | this.notificationPlatform = 33 | opts.notificationPlatform || opts.notification_platform || '' 34 | this.notificationDeviceToken = 35 | opts.notificationDeviceToken || opts.notification_device_token || '' 36 | 37 | if (!this.registrationAccessToken) { 38 | if (this.redirectURI === '') { 39 | throw new Error('Missing redirectURI field') 40 | } 41 | if (this.softwareID === '') { 42 | throw new Error('Missing softwareID field') 43 | } 44 | if (this.clientName === '') { 45 | throw new Error('Missing clientName field') 46 | } 47 | } 48 | } 49 | 50 | isRegistered() { 51 | return this.clientID !== '' 52 | } 53 | 54 | toRegisterJSON() { 55 | return { 56 | redirect_uris: [this.redirectURI], 57 | software_id: this.softwareID, 58 | software_version: this.softwareVersion, 59 | client_name: this.clientName, 60 | client_kind: this.clientKind, 61 | client_uri: this.clientURI, 62 | logo_uri: this.logoURI, 63 | policy_uri: this.policyURI, 64 | notification_platform: this.notificationPlatform, 65 | notification_device_token: this.notificationDeviceToken 66 | } 67 | } 68 | 69 | toAuthHeader() { 70 | return 'Bearer ' + this.registrationAccessToken 71 | } 72 | } 73 | 74 | export class AccessToken { 75 | constructor(opts) { 76 | this.tokenType = opts.tokenType || opts.token_type 77 | this.accessToken = opts.accessToken || opts.access_token 78 | this.refreshToken = opts.refreshToken || opts.refresh_token 79 | this.scope = opts.scope 80 | } 81 | 82 | toAuthHeader() { 83 | return 'Bearer ' + this.accessToken 84 | } 85 | 86 | toBasicAuth() { 87 | return `user:${this.accessToken}@` 88 | } 89 | } 90 | 91 | export class AppToken { 92 | constructor(opts) { 93 | this.token = opts.token || '' 94 | } 95 | 96 | toAuthHeader() { 97 | return 'Bearer ' + this.token 98 | } 99 | 100 | toBasicAuth() { 101 | return `user:${this.token}@` 102 | } 103 | } 104 | 105 | export function client(cozy, clientParams) { 106 | if (!clientParams) { 107 | clientParams = cozy._clientParams 108 | } 109 | if (clientParams instanceof Client) { 110 | return clientParams 111 | } 112 | return new Client(clientParams) 113 | } 114 | 115 | export function registerClient(cozy, clientParams) { 116 | const cli = client(cozy, clientParams) 117 | if (cli.isRegistered()) { 118 | return Promise.reject(new Error('Client already registered')) 119 | } 120 | return cozyFetchJSON(cozy, 'POST', '/auth/register', cli.toRegisterJSON(), { 121 | disableAuth: true 122 | }).then(data => new Client(data)) 123 | } 124 | 125 | export function updateClient(cozy, clientParams, resetSecret = false) { 126 | const cli = client(cozy, clientParams) 127 | if (!cli.isRegistered()) { 128 | return Promise.reject(new Error('Client not registered')) 129 | } 130 | let data = cli.toRegisterJSON() 131 | data.client_id = cli.clientID 132 | if (resetSecret) data.client_secret = cli.clientSecret 133 | 134 | return cozyFetchJSON(cozy, 'PUT', `/auth/register/${cli.clientID}`, data, { 135 | manualAuthCredentials: { 136 | token: cli 137 | } 138 | }).then(data => createClient(data, cli)) 139 | } 140 | 141 | export function unregisterClient(cozy, clientParams) { 142 | const cli = client(cozy, clientParams) 143 | if (!cli.isRegistered()) { 144 | return Promise.reject(new Error('Client not registered')) 145 | } 146 | return cozyFetchJSON(cozy, 'DELETE', `/auth/register/${cli.clientID}`, null, { 147 | manualAuthCredentials: { 148 | token: cli 149 | } 150 | }) 151 | } 152 | 153 | // getClient will retrive the registered client informations from the server. 154 | export function getClient(cozy, clientParams) { 155 | const cli = client(cozy, clientParams) 156 | if (!cli.isRegistered()) { 157 | return Promise.reject(new Error('Client not registered')) 158 | } 159 | if (isOffline()) { 160 | return Promise.resolve(cli) 161 | } 162 | return cozyFetchJSON(cozy, 'GET', `/auth/register/${cli.clientID}`, null, { 163 | manualAuthCredentials: { 164 | token: cli 165 | } 166 | }) 167 | .then(data => createClient(data, cli)) 168 | .catch(err => { 169 | // If we fall into an error while fetching the client (because of a 170 | // bad connectivity for instance), we do not bail the whole process 171 | // since the client should be able to continue with the persisted 172 | // client and token. 173 | // 174 | // If it is an explicit Unauthorized error though, we bail, clear th 175 | // cache and retry. 176 | if (FetchError.isUnauthorized(err) || FetchError.isNotFound(err)) { 177 | throw new Error('Client has been revoked') 178 | } 179 | throw err 180 | }) 181 | } 182 | 183 | // createClient returns a new Client instance given on object containing the 184 | // data of the client, from the API, and an old instance of the client. 185 | function createClient(data, oldClient) { 186 | const newClient = new Client(data) 187 | // we need to keep track of the registrationAccessToken since it is send 188 | // only on registration. The GET /auth/register/:client-id endpoint does 189 | // not return this token. 190 | const shouldPassRegistration = 191 | !!oldClient && 192 | oldClient.registrationAccessToken !== '' && 193 | newClient.registrationAccessToken === '' 194 | if (shouldPassRegistration) { 195 | newClient.registrationAccessToken = oldClient.registrationAccessToken 196 | } 197 | return newClient 198 | } 199 | 200 | // getAuthCodeURL returns a pair {authURL,state} given a registered client. The 201 | // state should be stored in order to be checked against on the user validation 202 | // phase. 203 | export function getAuthCodeURL(cozy, client, scopes = []) { 204 | if (!(client instanceof Client)) { 205 | client = new Client(client) 206 | } 207 | if (!client.isRegistered()) { 208 | throw new Error('Client not registered') 209 | } 210 | const state = generateRandomState() 211 | const query = { 212 | client_id: client.clientID, 213 | redirect_uri: client.redirectURI, 214 | state: state, 215 | response_type: 'code', 216 | scope: scopes.join(' ') 217 | } 218 | return { 219 | url: cozy._url + `/auth/authorize?${encodeQuery(query)}`, 220 | state: state 221 | } 222 | } 223 | 224 | // getAccessToken perform a request on the access_token entrypoint with the 225 | // authorization_code grant type in order to generate a new access token for a 226 | // newly registered client. 227 | // 228 | // This method extracts the access code and state from the given URL. By 229 | // default it uses window.location.href. Also, it checks the given state with 230 | // the one specified in the URL query parameter to prevent CSRF attacks. 231 | export function getAccessToken(cozy, client, state, pageURL = '') { 232 | if (!state) { 233 | return Promise.reject(new Error('Missing state value')) 234 | } 235 | const grantQueries = getGrantCodeFromPageURL(pageURL) 236 | if (grantQueries === null) { 237 | return Promise.reject(new Error('Missing states from current URL')) 238 | } 239 | if (state !== grantQueries.state) { 240 | return Promise.reject( 241 | new Error('Given state does not match url query state') 242 | ) 243 | } 244 | return retrieveToken(cozy, client, null, { 245 | grant_type: 'authorization_code', 246 | code: grantQueries.code 247 | }) 248 | } 249 | 250 | // refreshToken perform a request on the access_token entrypoint with the 251 | // refresh_token grant type in order to refresh the given token. 252 | export function refreshToken(cozy, client, token) { 253 | return retrieveToken(cozy, client, token, { 254 | grant_type: 'refresh_token', 255 | refresh_token: token.refreshToken 256 | }) 257 | } 258 | 259 | // oauthFlow performs the stateful registration and access granting of an OAuth 260 | // client. 261 | export function oauthFlow( 262 | cozy, 263 | storage, 264 | clientParams, 265 | onRegistered, 266 | ignoreCachedCredentials = false 267 | ) { 268 | if (ignoreCachedCredentials) { 269 | return storage 270 | .clear() 271 | .then(() => oauthFlow(cozy, storage, clientParams, onRegistered, false)) 272 | } 273 | 274 | let tryCount = 0 275 | 276 | function clearAndRetry(err) { 277 | if (tryCount++ > 0) { 278 | throw err 279 | } 280 | return storage 281 | .clear() 282 | .then(() => oauthFlow(cozy, storage, clientParams, onRegistered)) 283 | } 284 | 285 | function registerNewClient() { 286 | return storage 287 | .clear() 288 | .then(() => registerClient(cozy, clientParams)) 289 | .then(client => { 290 | const { url, state } = getAuthCodeURL(cozy, client, clientParams.scopes) 291 | return storage.save(StateKey, { client, url, state }) 292 | }) 293 | } 294 | 295 | return Promise.all([storage.load(CredsKey), storage.load(StateKey)]) 296 | .then(([credentials, storedState]) => { 297 | // If credentials are cached we re-fetch the registered client with the 298 | // said token. Fetching the client, if the token is outdated we should try 299 | // the token is refreshed. 300 | if (credentials) { 301 | let oldClient, token 302 | try { 303 | oldClient = new Client(credentials.client) 304 | token = new AccessToken(credentials.token) 305 | } catch (err) { 306 | // bad cache, we should clear and retry the process 307 | return clearAndRetry(err) 308 | } 309 | return getClient(cozy, oldClient) 310 | .then(client => ({ client, token })) 311 | .catch(err => { 312 | // If we fall into an error while fetching the client (because of a 313 | // bad connectivity for instance), we do not bail the whole process 314 | // since the client should be able to continue with the persisted 315 | // client and token. 316 | // 317 | // If it is an explicit Unauthorized error though, we bail, clear th 318 | // cache and retry. 319 | if (FetchError.isUnauthorized(err) || FetchError.isNotFound(err)) { 320 | throw new Error('Client has been revoked') 321 | } 322 | return { client: oldClient, token } 323 | }) 324 | } 325 | 326 | // Otherwise register a new client if necessary (ie. no client is stored) 327 | // and call the onRegistered callback to wait for the user to grant the 328 | // access. Finally fetches to access token on success. 329 | let statePromise 330 | if (!storedState) { 331 | statePromise = registerNewClient() 332 | } else { 333 | statePromise = Promise.resolve(storedState) 334 | } 335 | 336 | let client, state, token 337 | return statePromise 338 | .then(data => { 339 | client = data.client 340 | state = data.state 341 | return Promise.resolve(onRegistered(client, data.url)) 342 | }) 343 | .then(pageURL => getAccessToken(cozy, client, state, pageURL)) 344 | .then(t => { 345 | token = t 346 | }) 347 | .then(() => storage.delete(StateKey)) 348 | .then(() => ({ client, token })) 349 | }) 350 | .then( 351 | creds => storage.save(CredsKey, creds), 352 | err => { 353 | if (FetchError.isUnauthorized(err)) { 354 | return clearAndRetry(err) 355 | } else { 356 | throw err 357 | } 358 | } 359 | ) 360 | } 361 | 362 | // retrieveToken perform a request on the access_token entrypoint in order to 363 | // fetch a token. 364 | function retrieveToken(cozy, client, token, query) { 365 | if (!(client instanceof Client)) { 366 | client = new Client(client) 367 | } 368 | if (!client.isRegistered()) { 369 | return Promise.reject(new Error('Client not registered')) 370 | } 371 | const body = encodeQuery( 372 | Object.assign({}, query, { 373 | client_id: client.clientID, 374 | client_secret: client.clientSecret 375 | }) 376 | ) 377 | return cozyFetchJSON(cozy, 'POST', '/auth/access_token', body, { 378 | disableAuth: token === null, 379 | dontRetry: true, 380 | manualAuthCredentials: { client, token }, 381 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' } 382 | }).then(data => { 383 | data.refreshToken = data.refreshToken || query.refresh_token 384 | return new AccessToken(data) 385 | }) 386 | } 387 | 388 | // getGrantCodeFromPageURL extract the state and code query parameters from the 389 | // given url 390 | function getGrantCodeFromPageURL(pageURL = '') { 391 | if (pageURL === '' && typeof window !== 'undefined') { 392 | pageURL = window.location.href 393 | } 394 | const queries = decodeQuery(pageURL) 395 | if (!queries.hasOwnProperty('state')) { 396 | return null 397 | } 398 | return { 399 | state: queries['state'], 400 | code: queries['code'] 401 | } 402 | } 403 | 404 | // generateRandomState will try to generate a 128bits random value from a secure 405 | // pseudo random generator. It will fallback on Math.random if it cannot find 406 | // such generator. 407 | function generateRandomState() { 408 | let buffer 409 | if ( 410 | typeof window !== 'undefined' && 411 | typeof window.crypto !== 'undefined' && 412 | typeof window.crypto.getRandomValues === 'function' 413 | ) { 414 | buffer = new Uint8Array(StateSize) 415 | window.crypto.getRandomValues(buffer) 416 | } else { 417 | try { 418 | buffer = require('crypto').randomBytes(StateSize) 419 | } catch (e) { 420 | buffer = null 421 | } 422 | } 423 | if (!buffer) { 424 | buffer = new Array(StateSize) 425 | for (let i = 0; i < buffer.length; i++) { 426 | buffer[i] = Math.floor(Math.random() * 255) 427 | } 428 | } 429 | return btoa(String.fromCharCode.apply(null, buffer)) 430 | .replace(/=+$/, '') 431 | .replace(/\//g, '_') 432 | .replace(/\+/g, '-') 433 | } 434 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | import { createPath } from './utils' 2 | import { normalizeDoctype } from './doctypes' 3 | import { cozyFetchJSON } from './fetch' 4 | 5 | const NOREV = 'stack-v2-no-rev' 6 | 7 | export function create(cozy, doctype, attributes) { 8 | return cozy.isV2().then(isV2 => { 9 | doctype = normalizeDoctype(cozy, isV2, doctype) 10 | if (isV2) { 11 | attributes.docType = doctype 12 | } 13 | const path = createPath(cozy, isV2, doctype, attributes._id) 14 | const httpVerb = attributes._id ? 'PUT' : 'POST' 15 | delete attributes._id 16 | return cozyFetchJSON(cozy, httpVerb, path, attributes).then(resp => { 17 | if (isV2) { 18 | return find(cozy, doctype, resp._id) 19 | } else { 20 | return resp.data 21 | } 22 | }) 23 | }) 24 | } 25 | 26 | export function find(cozy, doctype, id) { 27 | return cozy.isV2().then(isV2 => { 28 | doctype = normalizeDoctype(cozy, isV2, doctype) 29 | 30 | if (!id) { 31 | return Promise.reject(new Error('Missing id parameter')) 32 | } 33 | 34 | const path = createPath(cozy, isV2, doctype, id) 35 | return cozyFetchJSON(cozy, 'GET', path).then(resp => { 36 | if (isV2) { 37 | return Object.assign(resp, { _rev: NOREV }) 38 | } else { 39 | return resp 40 | } 41 | }) 42 | }) 43 | } 44 | 45 | export function findMany(cozy, doctype, ids) { 46 | if (!(ids instanceof Array)) { 47 | return Promise.reject(new Error('Parameter ids must be a non-empty array')) 48 | } 49 | if (ids.length === 0) { 50 | // So users don't need to be defensive regarding the array content. 51 | // This should not hide issues in user code since the result will be an 52 | // empty object anyway. 53 | return Promise.resolve({}) 54 | } 55 | 56 | return cozy.isV2().then(isV2 => { 57 | if (isV2) { 58 | return Promise.reject(new Error('findMany is not available on v2')) 59 | } 60 | 61 | const path = createPath(cozy, isV2, doctype, '_all_docs', { 62 | include_docs: true 63 | }) 64 | 65 | return cozyFetchJSON(cozy, 'POST', path, { keys: ids }) 66 | .then(resp => { 67 | const docs = {} 68 | 69 | for (const row of resp.rows) { 70 | const { key, doc, error } = row 71 | docs[key] = error ? { error } : { doc } 72 | } 73 | 74 | return docs 75 | }) 76 | .catch(error => { 77 | if (error.status !== 404) return Promise.reject(error) 78 | 79 | // When no doc was ever created and the database does not exist yet, 80 | // the response will be a 404 error. 81 | const docs = {} 82 | 83 | for (const id of ids) { 84 | docs[id] = { error } 85 | } 86 | 87 | return docs 88 | }) 89 | }) 90 | } 91 | 92 | export function findAll(cozy, doctype, options = { include_docs: true }) { 93 | return cozy.isV2().then(isV2 => { 94 | if (isV2) { 95 | return Promise.reject(new Error('findAll is not available on v2')) 96 | } 97 | 98 | const path = createPath(cozy, isV2, doctype, '_all_docs', options) 99 | 100 | return cozyFetchJSON(cozy, 'POST', path, {}) 101 | .then(resp => { 102 | const docs = [] 103 | 104 | for (const row of resp.rows) { 105 | const { doc } = row 106 | // if not couchDB indexes 107 | if (!doc._id.match(/_design\//)) docs.push(doc) 108 | } 109 | return docs 110 | }) 111 | .catch(error => { 112 | // the _all_docs endpoint returns a 404 error if no document with the given 113 | // doctype exists. 114 | if (error.status === 404) return [] 115 | throw error 116 | }) 117 | }) 118 | } 119 | 120 | export function changesFeed(cozy, doctype, options) { 121 | return cozy.isV2().then(isV2 => { 122 | doctype = normalizeDoctype(cozy, isV2, doctype) 123 | const path = createPath(cozy, isV2, doctype, '_changes', options) 124 | return cozyFetchJSON(cozy, 'GET', path) 125 | }) 126 | } 127 | 128 | export function update(cozy, doctype, doc, changes) { 129 | return cozy.isV2().then(isV2 => { 130 | doctype = normalizeDoctype(cozy, isV2, doctype) 131 | const { _id, _rev } = doc 132 | 133 | if (!_id) { 134 | return Promise.reject(new Error('Missing _id field in passed document')) 135 | } 136 | 137 | if (!isV2 && !_rev) { 138 | return Promise.reject(new Error('Missing _rev field in passed document')) 139 | } 140 | 141 | if (isV2) { 142 | changes = Object.assign({ _id }, changes) 143 | } else { 144 | changes = Object.assign({ _id, _rev }, changes) 145 | } 146 | 147 | const path = createPath(cozy, isV2, doctype, _id) 148 | return cozyFetchJSON(cozy, 'PUT', path, changes).then(resp => { 149 | if (isV2) { 150 | return find(cozy, doctype, _id) 151 | } else { 152 | return resp.data 153 | } 154 | }) 155 | }) 156 | } 157 | 158 | export function updateAttributes(cozy, doctype, _id, changes, tries = 3) { 159 | return cozy.isV2().then(isV2 => { 160 | doctype = normalizeDoctype(cozy, isV2, doctype) 161 | return find(cozy, doctype, _id) 162 | .then(doc => { 163 | return update(cozy, doctype, doc, Object.assign({ _id }, doc, changes)) 164 | }) 165 | .catch(err => { 166 | if (tries > 0) { 167 | return updateAttributes(cozy, doctype, _id, changes, tries - 1) 168 | } else { 169 | throw err 170 | } 171 | }) 172 | }) 173 | } 174 | 175 | export function _delete(cozy, doctype, doc) { 176 | return cozy.isV2().then(isV2 => { 177 | doctype = normalizeDoctype(cozy, isV2, doctype) 178 | const { _id, _rev } = doc 179 | 180 | if (!_id) { 181 | return Promise.reject(new Error('Missing _id field in passed document')) 182 | } 183 | 184 | if (!isV2 && !_rev) { 185 | return Promise.reject(new Error('Missing _rev field in passed document')) 186 | } 187 | 188 | const query = isV2 ? null : { rev: _rev } 189 | const path = createPath(cozy, isV2, doctype, _id, query) 190 | return cozyFetchJSON(cozy, 'DELETE', path).then(resp => { 191 | if (isV2) { 192 | return { id: _id, rev: NOREV } 193 | } else { 194 | return resp 195 | } 196 | }) 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /src/doctypes.js: -------------------------------------------------------------------------------- 1 | import { warn } from './utils' 2 | 3 | export const DOCTYPE_FILES = 'io.cozy.files' 4 | 5 | const KNOWN_DOCTYPES = { 6 | files: DOCTYPE_FILES, 7 | folder: DOCTYPE_FILES, 8 | contact: 'io.cozy.contacts', 9 | event: 'io.cozy.events', 10 | track: 'io.cozy.labs.music.track', 11 | playlist: 'io.cozy.labs.music.playlist' 12 | } 13 | 14 | const REVERSE_KNOWN = {} 15 | Object.keys(KNOWN_DOCTYPES).forEach(k => { 16 | REVERSE_KNOWN[KNOWN_DOCTYPES[k]] = k 17 | }) 18 | 19 | export function normalizeDoctype(cozy, isV2, doctype) { 20 | let isQualified = doctype.indexOf('.') !== -1 21 | if (isV2 && isQualified) { 22 | let known = REVERSE_KNOWN[doctype] 23 | if (known) return known 24 | return doctype.replace(/\./g, '-') 25 | } 26 | if (!isV2 && !isQualified) { 27 | let known = KNOWN_DOCTYPES[doctype] 28 | if (known) { 29 | warn( 30 | 'you are using a non-qualified doctype ' + 31 | doctype + 32 | ' assumed to be ' + 33 | known 34 | ) 35 | return known 36 | } 37 | throw new Error('Doctype ' + doctype + ' should be qualified.') 38 | } 39 | return doctype 40 | } 41 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | import { refreshToken, AccessToken } from './auth_v3' 3 | import { retry, encodeQuery } from './utils' 4 | import jsonapi from './jsonapi' 5 | 6 | export function cozyFetch(cozy, path, options = {}) { 7 | return cozy.fullpath(path).then(fullpath => { 8 | let resp 9 | if (options.disableAuth) { 10 | resp = fetch(fullpath, options) 11 | } else if (options.manualAuthCredentials) { 12 | resp = cozyFetchWithAuth( 13 | cozy, 14 | fullpath, 15 | options, 16 | options.manualAuthCredentials 17 | ) 18 | } else { 19 | resp = cozy 20 | .authorize() 21 | .then(credentials => 22 | cozyFetchWithAuth(cozy, fullpath, options, credentials) 23 | ) 24 | } 25 | return resp.then(res => handleResponse(res, cozy._invalidTokenErrorHandler)) 26 | }) 27 | } 28 | 29 | function cozyFetchWithAuth(cozy, fullpath, options, credentials) { 30 | if (credentials) { 31 | options.headers = options.headers || {} 32 | options.headers['Authorization'] = credentials.token.toAuthHeader() 33 | } 34 | 35 | // the option credentials:include tells fetch to include the cookies in the 36 | // request even for cross-origin requests 37 | options.credentials = 'include' 38 | 39 | return Promise.all([cozy.isV2(), fetch(fullpath, options)]).then( 40 | ([isV2, res]) => { 41 | if ( 42 | (res.status !== 400 && res.status !== 401) || 43 | isV2 || 44 | !credentials || 45 | options.dontRetry 46 | ) { 47 | return res 48 | } 49 | // we try to refresh the token only for OAuth, ie, the client defined 50 | // and the token is an instance of AccessToken. 51 | const { client, token } = credentials 52 | if (!client || !(token instanceof AccessToken)) { 53 | return res 54 | } 55 | options.dontRetry = true 56 | return retry(() => refreshToken(cozy, client, token), 3)() 57 | .then(newToken => cozy.saveCredentials(client, newToken)) 58 | .then(credentials => 59 | cozyFetchWithAuth(cozy, fullpath, options, credentials) 60 | ) 61 | } 62 | ) 63 | } 64 | 65 | export function cozyFetchJSON(cozy, method, path, body, options = {}) { 66 | const processJSONAPI = 67 | typeof options.processJSONAPI === 'undefined' || options.processJSONAPI 68 | return fetchJSON(cozy, method, path, body, options).then(response => 69 | handleJSONResponse(response, processJSONAPI) 70 | ) 71 | } 72 | 73 | export function cozyFetchRawJSON(cozy, method, path, body, options = {}) { 74 | return fetchJSON(cozy, method, path, body, options).then(response => 75 | handleJSONResponse(response, false) 76 | ) 77 | } 78 | 79 | function fetchJSON(cozy, method, path, body, options = {}) { 80 | options.method = method 81 | 82 | const headers = (options.headers = options.headers || {}) 83 | 84 | headers['Accept'] = 'application/json' 85 | 86 | if (method !== 'GET' && method !== 'HEAD' && body !== undefined) { 87 | if (headers['Content-Type']) { 88 | options.body = body 89 | } else { 90 | headers['Content-Type'] = 'application/json' 91 | options.body = JSON.stringify(body) 92 | } 93 | } 94 | 95 | return cozyFetch(cozy, path, options) 96 | } 97 | 98 | function handleResponse(res, invalidTokenErrorHandler) { 99 | if (res.ok) { 100 | return res 101 | } 102 | let data 103 | const contentType = res.headers.get('content-type') 104 | if (contentType && contentType.indexOf('json') >= 0) { 105 | data = res.json() 106 | } else { 107 | data = res.text() 108 | } 109 | return data.then(err => { 110 | const error = new FetchError(res, err) 111 | if (FetchError.isInvalidToken(error) && invalidTokenErrorHandler) { 112 | invalidTokenErrorHandler(error) 113 | } 114 | throw error 115 | }) 116 | } 117 | 118 | function handleJSONResponse(res, processJSONAPI = true) { 119 | const contentType = res.headers.get('content-type') 120 | if (!contentType || contentType.indexOf('json') < 0) { 121 | return res.text(data => { 122 | throw new FetchError(res, new Error('Response is not JSON: ' + data)) 123 | }) 124 | } 125 | 126 | const json = res.json() 127 | if (contentType.indexOf('application/vnd.api+json') === 0 && processJSONAPI) { 128 | return json.then(jsonapi) 129 | } else { 130 | return json 131 | } 132 | } 133 | 134 | export function handleInvalidTokenError(error) { 135 | try { 136 | const currentOrigin = window.location.origin 137 | const requestUrl = error.url 138 | 139 | if ( 140 | requestUrl.indexOf( 141 | currentOrigin.replace(/^(https?:\/\/\w+)-\w+\./, '$1.') 142 | ) === 0 143 | ) { 144 | const redirectURL = `${currentOrigin}?${encodeQuery({ disconnect: 1 })}` 145 | window.location = redirectURL 146 | } 147 | } catch (e) { 148 | console.warn('Unable to handle invalid token error', e, error) 149 | } 150 | } 151 | 152 | export class FetchError extends Error { 153 | constructor(res, reason) { 154 | super() 155 | if (Error.captureStackTrace) { 156 | Error.captureStackTrace(this, this.constructor) 157 | } 158 | // XXX We have to hardcode this because babel doesn't play nice when extending Error 159 | this.name = 'FetchError' 160 | this.response = res 161 | this.url = res.url 162 | this.status = res.status 163 | this.reason = reason 164 | 165 | Object.defineProperty(this, 'message', { 166 | value: 167 | reason.message || 168 | (typeof reason === 'string' ? reason : JSON.stringify(reason)) 169 | }) 170 | } 171 | } 172 | 173 | FetchError.isUnauthorized = function(err) { 174 | // XXX We can't use err instanceof FetchError because of the caveats of babel 175 | return err.name === 'FetchError' && err.status === 401 176 | } 177 | 178 | FetchError.isNotFound = function(err) { 179 | // XXX We can't use err instanceof FetchError because of the caveats of babel 180 | return err.name === 'FetchError' && err.status === 404 181 | } 182 | 183 | FetchError.isInvalidToken = function(err) { 184 | // XXX We can't use err instanceof FetchError because of the caveats of babel 185 | return ( 186 | err.name === 'FetchError' && 187 | (err.status === 400 || err.status === 401) && 188 | err.reason && 189 | (err.reason.error === 'Invalid JWT token' || 190 | err.reason.error === 'Expired token') 191 | ) 192 | } 193 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* global fetch URL */ 2 | import { unpromiser, retry, warn } from './utils' 3 | import { LocalStorage, MemoryStorage } from './auth_storage' 4 | import { AppToken as AppTokenV2, getAppToken as getAppTokenV2 } from './auth_v2' 5 | import * as auth from './auth_v3' 6 | import * as data from './data' 7 | import * as cozyFetch from './fetch' 8 | import * as mango from './mango' 9 | import * as files from './files' 10 | import * as intents from './intents/' 11 | import * as jobs from './jobs' 12 | import * as offline from './offline' 13 | import * as settings from './settings' 14 | import * as relations from './relations' 15 | 16 | const { 17 | AppToken: AppTokenV3, 18 | AccessToken: AccessTokenV3, 19 | Client: ClientV3 20 | } = auth 21 | 22 | const AuthNone = 0 23 | const AuthRunning = 1 24 | const AuthError = 2 25 | const AuthOK = 3 26 | 27 | const defaultClientParams = { 28 | softwareID: 'github.com/cozy/cozy-client-js' 29 | } 30 | 31 | const dataProto = { 32 | create: data.create, 33 | find: data.find, 34 | findMany: data.findMany, 35 | findAll: data.findAll, 36 | update: data.update, 37 | delete: data._delete, 38 | updateAttributes: data.updateAttributes, 39 | changesFeed: data.changesFeed, 40 | defineIndex: mango.defineIndex, 41 | query: mango.query, 42 | addReferencedFiles: relations.addReferencedFiles, 43 | removeReferencedFiles: relations.removeReferencedFiles, 44 | listReferencedFiles: relations.listReferencedFiles, 45 | fetchReferencedFiles: relations.fetchReferencedFiles, 46 | destroy: function(...args) { 47 | warn('destroy is deprecated, use cozy.data.delete instead.') 48 | return data._delete(...args) 49 | } 50 | } 51 | 52 | const authProto = { 53 | client: auth.client, 54 | registerClient: auth.registerClient, 55 | updateClient: auth.updateClient, 56 | unregisterClient: auth.unregisterClient, 57 | getClient: auth.getClient, 58 | getAuthCodeURL: auth.getAuthCodeURL, 59 | getAccessToken: auth.getAccessToken, 60 | refreshToken: auth.refreshToken 61 | } 62 | 63 | const filesProto = { 64 | create: files.create, 65 | createDirectory: files.createDirectory, 66 | createDirectoryByPath: files.createDirectoryByPath, 67 | updateById: files.updateById, 68 | updateAttributesById: files.updateAttributesById, 69 | updateAttributesByPath: files.updateAttributesByPath, 70 | trashById: files.trashById, 71 | statById: files.statById, 72 | statByPath: files.statByPath, 73 | downloadById: files.downloadById, 74 | downloadByPath: files.downloadByPath, 75 | getDownloadLinkById: files.getDownloadLinkById, 76 | getDownloadLink: files.getDownloadLinkByPath, // DEPRECATED, should be removed very soon 77 | getDownloadLinkByPath: files.getDownloadLinkByPath, 78 | getArchiveLink: function(...args) { 79 | warn( 80 | 'getArchiveLink is deprecated, use cozy.files.getArchiveLinkByPaths instead.' 81 | ) 82 | return files.getArchiveLinkByPaths(...args) 83 | }, 84 | getArchiveLinkByPaths: files.getArchiveLinkByPaths, 85 | getArchiveLinkByIds: files.getArchiveLinkByIds, 86 | getFilePath: files.getFilePath, 87 | getCollectionShareLink: files.getCollectionShareLink, 88 | query: mango.queryFiles, 89 | listTrash: files.listTrash, 90 | clearTrash: files.clearTrash, 91 | restoreById: files.restoreById, 92 | destroyById: files.destroyById 93 | } 94 | 95 | const intentsProto = { 96 | create: intents.create, 97 | createService: intents.createService, 98 | getRedirectionURL: intents.getRedirectionURL, 99 | redirect: intents.redirect 100 | } 101 | 102 | const jobsProto = { 103 | create: jobs.create, 104 | count: jobs.count, 105 | queued: jobs.queued 106 | } 107 | 108 | const offlineProto = { 109 | init: offline.init, 110 | getDoctypes: offline.getDoctypes, 111 | // database 112 | hasDatabase: offline.hasDatabase, 113 | getDatabase: offline.getDatabase, 114 | createDatabase: offline.createDatabase, 115 | migrateDatabase: offline.migrateDatabase, 116 | destroyDatabase: offline.destroyDatabase, 117 | destroyAllDatabase: offline.destroyAllDatabase, 118 | // replication 119 | hasReplication: offline.hasReplication, 120 | replicateFromCozy: offline.replicateFromCozy, 121 | stopReplication: offline.stopReplication, 122 | stopAllReplication: offline.stopAllReplication, 123 | // repeated replication 124 | hasRepeatedReplication: offline.hasRepeatedReplication, 125 | startRepeatedReplication: offline.startRepeatedReplication, 126 | stopRepeatedReplication: offline.stopRepeatedReplication, 127 | stopAllRepeatedReplication: offline.stopAllRepeatedReplication 128 | } 129 | 130 | const settingsProto = { 131 | diskUsage: settings.diskUsage, 132 | changePassphrase: settings.changePassphrase, 133 | getInstance: settings.getInstance, 134 | updateInstance: settings.updateInstance, 135 | getClients: settings.getClients, 136 | deleteClientById: settings.deleteClientById, 137 | updateLastSync: settings.updateLastSync 138 | } 139 | 140 | const ensureHasReconnectParam = _url => { 141 | const url = new URL(_url) 142 | if (url.searchParams && !url.searchParams.has('reconnect')) { 143 | url.searchParams.append('reconnect', 1) 144 | } else if (!url.search || url.search.indexOf('reconnect') === -1) { 145 | // Some old navigators do not have the searchParams API 146 | // and it is not polyfilled by babel-polyfill 147 | url.search = url.search + '&reconnect=1' 148 | } 149 | return url.toString() 150 | } 151 | 152 | class Client { 153 | constructor(options) { 154 | this.data = {} 155 | this.files = {} 156 | this.intents = {} 157 | this.jobs = {} 158 | this.offline = {} 159 | this.settings = {} 160 | this.auth = { 161 | Client: ClientV3, 162 | AccessToken: AccessTokenV3, 163 | AppToken: AppTokenV3, 164 | AppTokenV2: AppTokenV2, 165 | LocalStorage: LocalStorage, 166 | MemoryStorage: MemoryStorage 167 | } 168 | this._inited = false 169 | if (options) { 170 | this.init(options) 171 | } 172 | } 173 | 174 | init(options = {}) { 175 | this._inited = true 176 | this._oauth = false // is oauth activated or not 177 | this._token = null // application token 178 | this._authstate = AuthNone 179 | this._authcreds = null 180 | this._storage = null 181 | this._version = options.version || null 182 | this._offline = null 183 | 184 | const token = options.token 185 | const oauth = options.oauth 186 | if (token && oauth) { 187 | throw new Error( 188 | 'Cannot specify an application token with a oauth activated' 189 | ) 190 | } 191 | 192 | if (token) { 193 | this._token = new AppTokenV3({ token }) 194 | } else if (oauth) { 195 | this._oauth = true 196 | this._storage = oauth.storage 197 | this._clientParams = Object.assign( 198 | {}, 199 | defaultClientParams, 200 | oauth.clientParams 201 | ) 202 | this._onRegistered = oauth.onRegistered || nopOnRegistered 203 | } 204 | 205 | let url = options.cozyURL || '' 206 | while (url[url.length - 1] === '/') { 207 | url = url.slice(0, -1) 208 | } 209 | 210 | this._url = url 211 | 212 | this._invalidTokenErrorHandler = 213 | options.onInvalidTokenError !== undefined 214 | ? options.onInvalidTokenError 215 | : cozyFetch.handleInvalidTokenError 216 | 217 | const disablePromises = !!options.disablePromises 218 | addToProto(this, this.data, dataProto, disablePromises) 219 | addToProto(this, this.auth, authProto, disablePromises) 220 | addToProto(this, this.files, filesProto, disablePromises) 221 | addToProto(this, this.intents, intentsProto, disablePromises) 222 | addToProto(this, this.jobs, jobsProto, disablePromises) 223 | addToProto(this, this.offline, offlineProto, disablePromises) 224 | addToProto(this, this.settings, settingsProto, disablePromises) 225 | 226 | if (options.offline) { 227 | this.offline.init(options.offline) 228 | } 229 | 230 | this.fetch = function _fetch(method, url, options = {}) { 231 | return cozyFetch.cozyFetch(this, url, { ...options, method }) 232 | } 233 | 234 | this.fetchJSON = function _fetchJSON() { 235 | const args = [this].concat(Array.prototype.slice.call(arguments)) 236 | return cozyFetch.cozyFetchJSON.apply(this, args) 237 | } 238 | } 239 | 240 | authorize(forceTokenRefresh = false) { 241 | const state = this._authstate 242 | if (state === AuthOK || state === AuthRunning) { 243 | return this._authcreds 244 | } 245 | 246 | this._authstate = AuthRunning 247 | this._authcreds = this.isV2().then(isV2 => { 248 | if (isV2 && this._oauth) { 249 | throw new Error('OAuth is not supported on the V2 stack') 250 | } 251 | if (this._oauth) { 252 | if (forceTokenRefresh && this._clientParams.redirectURI) { 253 | this._clientParams.redirectURI = ensureHasReconnectParam( 254 | this._clientParams.redirectURI 255 | ) 256 | } 257 | return auth.oauthFlow( 258 | this, 259 | this._storage, 260 | this._clientParams, 261 | this._onRegistered, 262 | forceTokenRefresh 263 | ) 264 | } 265 | // we expect to be on a client side application running in a browser 266 | // with cookie-based authentication. 267 | if (isV2) { 268 | return getAppTokenV2() 269 | } else if (this._token) { 270 | return Promise.resolve({ client: null, token: this._token }) 271 | } else { 272 | throw new Error('Missing application token') 273 | } 274 | }) 275 | 276 | this._authcreds.then( 277 | () => { 278 | this._authstate = AuthOK 279 | }, 280 | () => { 281 | this._authstate = AuthError 282 | } 283 | ) 284 | 285 | return this._authcreds 286 | } 287 | 288 | saveCredentials(client, token) { 289 | const creds = { client, token } 290 | if (!this._storage || this._authstate === AuthRunning) { 291 | return Promise.resolve(creds) 292 | } 293 | this._storage.save(auth.CredsKey, creds) 294 | this._authcreds = Promise.resolve(creds) 295 | return this._authcreds 296 | } 297 | 298 | fullpath(path) { 299 | return this.isV2().then(isV2 => { 300 | const pathprefix = isV2 ? '/ds-api' : '' 301 | return this._url + pathprefix + path 302 | }) 303 | } 304 | 305 | isV2() { 306 | if (!this._version) { 307 | return retry(() => fetch(`${this._url}/status/`), 3)() 308 | .then(res => { 309 | if (!res.ok) { 310 | throw new Error('Could not fetch cozy status') 311 | } else { 312 | return res.json() 313 | } 314 | }) 315 | .then(status => { 316 | this._version = status.datasystem !== undefined ? 2 : 3 317 | return this.isV2() 318 | }) 319 | } 320 | return Promise.resolve(this._version === 2) 321 | } 322 | } 323 | 324 | function nopOnRegistered() { 325 | throw new Error('Missing onRegistered callback') 326 | } 327 | 328 | function protoify(context, fn) { 329 | return function prototyped(...args) { 330 | return fn(context, ...args) 331 | } 332 | } 333 | 334 | function addToProto(ctx, obj, proto, disablePromises) { 335 | for (const attr in proto) { 336 | let fn = protoify(ctx, proto[attr]) 337 | if (disablePromises) { 338 | fn = unpromiser(fn) 339 | } 340 | obj[attr] = fn 341 | } 342 | } 343 | 344 | module.exports = new Client() 345 | Object.assign(module.exports, { Client, LocalStorage, MemoryStorage }) 346 | -------------------------------------------------------------------------------- /src/intents/client.js: -------------------------------------------------------------------------------- 1 | import { errorSerializer, pickService } from './helpers' 2 | import { create as createIntent } from './' 3 | 4 | const intentClass = 'coz-intent' 5 | 6 | function hideIntentIframe(iframe) { 7 | iframe.style.display = 'none' 8 | } 9 | 10 | function showIntentFrame(iframe) { 11 | iframe.style.display = 'block' 12 | } 13 | 14 | function buildIntentIframe(intent, element, url) { 15 | const document = element.ownerDocument 16 | if (!document) 17 | return Promise.reject( 18 | new Error('Cannot retrieve document object from given element') 19 | ) 20 | 21 | const iframe = document.createElement('iframe') 22 | // TODO: implement 'title' attribute 23 | iframe.setAttribute('id', `intent-${intent._id}`) 24 | iframe.setAttribute('src', url) 25 | iframe.classList.add(intentClass) 26 | return iframe 27 | } 28 | 29 | function injectIntentIframe(intent, element, url, options) { 30 | const { onReadyCallback } = options 31 | const iframe = buildIntentIframe( 32 | intent, 33 | element, 34 | url, 35 | options.onReadyCallback 36 | ) 37 | // if callback provided for when iframe is loaded 38 | if (typeof onReadyCallback === 'function') iframe.onload = onReadyCallback 39 | element.appendChild(iframe) 40 | iframe.focus() 41 | return iframe 42 | } 43 | 44 | // inject iframe for service in given element 45 | function connectIntentIframe(cozy, iframe, element, intent, data) { 46 | const document = element.ownerDocument 47 | if (!document) 48 | return Promise.reject( 49 | new Error('Cannot retrieve document object from given element') 50 | ) 51 | 52 | const window = document.defaultView 53 | if (!window) 54 | return Promise.reject( 55 | new Error('Cannot retrieve window object from document') 56 | ) 57 | 58 | // Keeps only http://domain:port/ 59 | const serviceOrigin = iframe.src.split('/', 3).join('/') 60 | 61 | async function compose(cozy, action, doctype, data) { 62 | const intent = await createIntent(cozy, action, doctype, data) 63 | hideIntentIframe(iframe) 64 | const doc = await start(cozy, intent, element, { 65 | ...data, 66 | exposeIntentFrameRemoval: false 67 | }) 68 | showIntentFrame(iframe) 69 | return doc 70 | } 71 | 72 | return new Promise((resolve, reject) => { 73 | let handshaken = false 74 | const messageHandler = async event => { 75 | if (event.origin !== serviceOrigin) return 76 | 77 | const eventType = event.data.type 78 | if (eventType === 'load') { 79 | // Safari 9.1 (At least) send a MessageEvent when the iframe loads, 80 | // making the handshake fails. 81 | console.warn && 82 | console.warn( 83 | 'Cozy Client ignored MessageEvent having data.type `load`.' 84 | ) 85 | return 86 | } 87 | 88 | if (eventType === `intent-${intent._id}:ready`) { 89 | handshaken = true 90 | return event.source.postMessage(data, event.origin) 91 | } 92 | 93 | if (handshaken && eventType === `intent-${intent._id}:resize`) { 94 | ;['width', 'height', 'maxWidth', 'maxHeight'].forEach(prop => { 95 | if (event.data.transition) 96 | element.style.transition = event.data.transition 97 | if (event.data.dimensions[prop]) 98 | element.style[prop] = `${event.data.dimensions[prop]}px` 99 | }) 100 | 101 | return true 102 | } 103 | 104 | if (handshaken && eventType === `intent-${intent._id}:compose`) { 105 | // Let start to name `type` as `doctype`, as `event.data` already have a `type` attribute. 106 | const { action, doctype, data } = event.data 107 | const doc = await compose( 108 | cozy, 109 | action, 110 | doctype, 111 | data 112 | ) 113 | return event.source.postMessage(doc, event.origin) 114 | } 115 | 116 | window.removeEventListener('message', messageHandler) 117 | const removeIntentFrame = () => { 118 | // check if the parent node has not been already removed from the DOM 119 | iframe.parentNode && iframe.parentNode.removeChild(iframe) 120 | } 121 | 122 | if ( 123 | handshaken && 124 | eventType === `intent-${intent._id}:exposeFrameRemoval` 125 | ) { 126 | return resolve({ removeIntentFrame, doc: event.data.document }) 127 | } 128 | 129 | removeIntentFrame() 130 | 131 | if (eventType === `intent-${intent._id}:error`) { 132 | return reject(errorSerializer.deserialize(event.data.error)) 133 | } 134 | 135 | if (handshaken && eventType === `intent-${intent._id}:cancel`) { 136 | return resolve(null) 137 | } 138 | 139 | if (handshaken && eventType === `intent-${intent._id}:done`) { 140 | return resolve(event.data.document) 141 | } 142 | 143 | if (!handshaken) { 144 | return reject( 145 | new Error('Unexpected handshake message from intent service') 146 | ) 147 | } 148 | 149 | // We may be in a state where the messageHandler is still attached to then 150 | // window, but will not be needed anymore. For example, the service failed 151 | // before adding the `unload` listener, so no `intent:cancel` message has 152 | // never been sent. 153 | // So we simply ignore other messages, and this listener will stay here, 154 | // waiting for a message which will never come, forever (almost). 155 | } 156 | 157 | window.addEventListener('message', messageHandler) 158 | }) 159 | } 160 | 161 | export function start(cozy, intent, element, data = {}, options = {}) { 162 | const service = pickService(intent, options.filterServices) 163 | 164 | if (!service) { 165 | throw new Error('Unable to find a service') 166 | } 167 | 168 | const iframe = injectIntentIframe(intent, element, service.href, options) 169 | 170 | return connectIntentIframe( 171 | cozy, 172 | iframe, 173 | element, 174 | intent, 175 | data, 176 | options.onReadyCallback 177 | ) 178 | } 179 | -------------------------------------------------------------------------------- /src/intents/helpers.js: -------------------------------------------------------------------------------- 1 | // helper to serialize/deserialize an error for/from postMessage 2 | export const errorSerializer = (() => { 3 | function mapErrorProperties(from, to) { 4 | const result = Object.assign(to, from) 5 | const nativeProperties = ['name', 'message'] 6 | return nativeProperties.reduce((result, property) => { 7 | if (from[property]) { 8 | to[property] = from[property] 9 | } 10 | return result 11 | }, result) 12 | } 13 | return { 14 | serialize: error => mapErrorProperties(error, {}), 15 | deserialize: data => mapErrorProperties(data, new Error(data.message)) 16 | } 17 | })() 18 | 19 | const first = arr => arr && arr[0] 20 | // In a far future, the user will have to pick the desired service from a list. 21 | // For now it's our job, an easy job as we arbitrary pick the first service of 22 | // the list. 23 | export function pickService(intent, filterServices) { 24 | const services = intent.attributes.services 25 | const filteredServices = filterServices 26 | ? (services || []).filter(filterServices) 27 | : services 28 | return first(filteredServices) 29 | } 30 | -------------------------------------------------------------------------------- /src/intents/index.js: -------------------------------------------------------------------------------- 1 | import { cozyFetchJSON } from '../fetch' 2 | import { pickService } from './helpers' 3 | import * as client from './client' 4 | import * as service from './service' 5 | 6 | export function create(cozy, action, type, data = {}, permissions = []) { 7 | if (!action) 8 | throw new Error(`Misformed intent, "action" property must be provided`) 9 | if (!type) 10 | throw new Error(`Misformed intent, "type" property must be provided`) 11 | 12 | const createPromise = cozyFetchJSON(cozy, 'POST', '/intents', { 13 | data: { 14 | type: 'io.cozy.intents', 15 | attributes: { 16 | action: action, 17 | type: type, 18 | data: data, 19 | permissions: permissions 20 | } 21 | } 22 | }) 23 | 24 | createPromise.start = (element, onReadyCallback) => { 25 | const options = { 26 | filteredServices: data.filteredServices, 27 | onReadyCallback: onReadyCallback 28 | } 29 | 30 | delete data.filteredServices 31 | 32 | return createPromise.then(intent => 33 | client.start(cozy, intent, element, data, options) 34 | ) 35 | } 36 | 37 | return createPromise 38 | } 39 | 40 | // returns a service to communicate with intent client 41 | export function createService(cozy, intentId, serviceWindow) { 42 | return service.start(cozy, intentId, serviceWindow) 43 | } 44 | 45 | function removeQueryString(url) { 46 | return url.replace(/\?[^/#]*/, '') 47 | } 48 | 49 | // Redirect to an app able to handle the doctype 50 | // Redirections are more or less a hack of the intent API to retrieve an URL for 51 | // accessing a given doctype or a given document. 52 | // It needs to use a special action `REDIRECT` 53 | export async function getRedirectionURL(cozy, type, data) { 54 | if (!type && !data) 55 | throw new Error( 56 | `Cannot retrieve redirection, at least type or doc must be provided` 57 | ) 58 | 59 | const intent = await create(cozy, 'REDIRECT', type, data) 60 | 61 | const service = pickService(intent) 62 | if (!service) throw new Error('Unable to find a service') 63 | 64 | // Intents cannot be deleted now 65 | // await deleteIntent(cozy, intent) 66 | 67 | const baseURL = removeQueryString(service.href) 68 | return data ? buildRedirectionURL(baseURL, data) : baseURL 69 | } 70 | 71 | function isSerializable(value) { 72 | return !['object', 'function'].includes(typeof value) 73 | } 74 | 75 | function buildRedirectionURL(url, data) { 76 | const parameterStrings = Object.keys(data) 77 | .filter(key => isSerializable(data[key])) 78 | .map(key => `${key}=${data[key]}`) 79 | 80 | return parameterStrings.length ? `${url}?${parameterStrings.join('&')}` : url 81 | } 82 | 83 | export async function redirect(cozy, type, doc, redirectFn) { 84 | if (!window) 85 | throw new Error('redirect() method can only be called in a browser') 86 | const redirectionURL = await getRedirectionURL(cozy, type, doc) 87 | if (redirectFn && typeof redirectFn === 'function') { 88 | return redirectFn(redirectionURL) 89 | } 90 | 91 | window.location.href = redirectionURL 92 | } 93 | -------------------------------------------------------------------------------- /src/intents/service.js: -------------------------------------------------------------------------------- 1 | import { cozyFetchJSON } from '../fetch' 2 | import { errorSerializer } from './helpers' 3 | 4 | function listenClientData(intent, window) { 5 | return new Promise(resolve => { 6 | const messageEventListener = event => { 7 | if (event.origin !== intent.attributes.client) return 8 | 9 | window.removeEventListener('message', messageEventListener) 10 | resolve(event.data) 11 | } 12 | 13 | window.addEventListener('message', messageEventListener) 14 | window.parent.postMessage( 15 | { 16 | type: `intent-${intent._id}:ready` 17 | }, 18 | intent.attributes.client 19 | ) 20 | }) 21 | } 22 | 23 | // maximize the height of an element 24 | function maximize(element) { 25 | if (element && element.style) { 26 | element.style.height = '100%' 27 | } 28 | } 29 | 30 | export function start(cozy, intentId, serviceWindow) { 31 | serviceWindow = serviceWindow || (typeof window !== 'undefined' && window) 32 | if (!serviceWindow || !serviceWindow.document) { 33 | return Promise.reject(new Error('Intent service should be used in browser')) 34 | } 35 | 36 | // Maximize document, the whole iframe is handled by intents, clients and 37 | // services 38 | serviceWindow.addEventListener('load', () => { 39 | const { document } = serviceWindow 40 | ;[document.documentElement, document.body].forEach(maximize) 41 | }) 42 | 43 | intentId = intentId || serviceWindow.location.search.split('=')[1] 44 | if (!intentId) 45 | return Promise.reject(new Error('Cannot retrieve intent from URL')) 46 | 47 | return cozyFetchJSON(cozy, 'GET', `/intents/${intentId}`).then(intent => { 48 | let terminated = false 49 | 50 | const sendMessage = message => { 51 | if (terminated) 52 | throw new Error('Intent service has already been terminated') 53 | serviceWindow.parent.postMessage(message, intent.attributes.client) 54 | } 55 | 56 | const compose = (action, doctype, data) => 57 | new Promise(resolve => { 58 | const composeEventListener = event => { 59 | if (event.origin !== intent.attributes.client) return 60 | serviceWindow.removeEventListener('message', composeEventListener) 61 | return resolve(event.data) 62 | } 63 | 64 | serviceWindow.addEventListener('message', composeEventListener) 65 | 66 | sendMessage({ 67 | type: `intent-${intent._id}:compose`, 68 | action, 69 | doctype, 70 | data 71 | }) 72 | }) 73 | 74 | const terminate = message => { 75 | sendMessage(message) 76 | terminated = true 77 | } 78 | 79 | const resizeClient = (dimensions, transitionProperty) => { 80 | if (terminated) throw new Error('Intent service has been terminated') 81 | 82 | sendMessage({ 83 | type: `intent-${intent._id}:resize`, 84 | // if a dom element is passed, calculate its size 85 | dimensions: dimensions.element 86 | ? Object.assign({}, dimensions, { 87 | maxHeight: dimensions.element.clientHeight, 88 | maxWidth: dimensions.element.clientWidth 89 | }) 90 | : dimensions, 91 | transition: transitionProperty 92 | }) 93 | } 94 | 95 | const cancel = () => { 96 | terminate({ type: `intent-${intent._id}:cancel` }) 97 | } 98 | 99 | // Prevent unfulfilled client promises when this window unloads for a 100 | // reason or another. 101 | serviceWindow.addEventListener('unload', () => { 102 | if (!terminated) cancel() 103 | }) 104 | 105 | return listenClientData(intent, serviceWindow).then(data => { 106 | return { 107 | compose: compose, 108 | getData: () => data, 109 | getIntent: () => intent, 110 | terminate: doc => { 111 | const eventName = 112 | data && data.exposeIntentFrameRemoval 113 | ? 'exposeFrameRemoval' 114 | : 'done' 115 | return terminate({ 116 | type: `intent-${intent._id}:${eventName}`, 117 | document: doc 118 | }) 119 | }, 120 | throw: error => 121 | terminate({ 122 | type: `intent-${intent._id}:error`, 123 | error: errorSerializer.serialize(error) 124 | }), 125 | resizeClient: resizeClient, 126 | cancel: cancel 127 | } 128 | }) 129 | }) 130 | } 131 | -------------------------------------------------------------------------------- /src/jobs.js: -------------------------------------------------------------------------------- 1 | import { cozyFetchJSON } from './fetch' 2 | 3 | export function count(cozy, workerType) { 4 | return cozyFetchJSON(cozy, 'GET', `/jobs/queue/${workerType}`).then( 5 | data => data.length 6 | ) 7 | } 8 | 9 | export function queued(cozy, workerType) { 10 | return cozyFetchJSON(cozy, 'GET', `/jobs/queue/${workerType}`) 11 | } 12 | 13 | export function create(cozy, workerType, args, options) { 14 | return cozyFetchJSON(cozy, 'POST', `/jobs/queue/${workerType}`, { 15 | data: { 16 | type: 'io.cozy.jobs', 17 | attributes: { 18 | arguments: args || {}, 19 | options: options || {} 20 | } 21 | } 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/jsonapi.js: -------------------------------------------------------------------------------- 1 | function indexKey(doc) { 2 | return doc.type + '/' + doc.id 3 | } 4 | 5 | function findByRef(resources, ref) { 6 | return resources[indexKey(ref)] 7 | } 8 | 9 | function handleResource(rawResource, resources, links) { 10 | let resource = { 11 | _id: rawResource.id, 12 | _type: rawResource.type, 13 | _rev: rawResource.meta && rawResource.meta.rev, 14 | links: Object.assign({}, rawResource.links, links), 15 | attributes: rawResource.attributes, 16 | relations: name => { 17 | let rels = rawResource.relationships[name] 18 | if (rels === undefined || rels.data === undefined) return undefined 19 | if (rels.data === null) return null 20 | if (!Array.isArray(rels.data)) return findByRef(resources, rels.data) 21 | return rels.data.map(ref => findByRef(resources, ref)) 22 | } 23 | } 24 | if (rawResource.relationships) { 25 | resource.relationships = rawResource.relationships 26 | } 27 | 28 | resources[indexKey(rawResource)] = resource 29 | 30 | return resource 31 | } 32 | 33 | function handleTopLevel(doc, resources = {}) { 34 | // build an index of included resource by Type & ID 35 | const included = doc.included 36 | 37 | if (Array.isArray(included)) { 38 | included.forEach(r => handleResource(r, resources, doc.links)) 39 | } 40 | 41 | if (Array.isArray(doc.data)) { 42 | return doc.data.map(r => handleResource(r, resources, doc.links)) 43 | } else { 44 | return handleResource(doc.data, resources, doc.links) 45 | } 46 | } 47 | 48 | export default handleTopLevel 49 | -------------------------------------------------------------------------------- /src/mango.js: -------------------------------------------------------------------------------- 1 | import { warn, createPath, sleep } from './utils' 2 | import { normalizeDoctype } from './doctypes' 3 | import { cozyFetchJSON, cozyFetchRawJSON } from './fetch' 4 | 5 | export function defineIndex(cozy, doctype, fields) { 6 | return cozy.isV2().then(isV2 => { 7 | doctype = normalizeDoctype(cozy, isV2, doctype) 8 | if (!Array.isArray(fields) || fields.length === 0) { 9 | throw new Error('defineIndex fields should be a non-empty array') 10 | } 11 | if (isV2) { 12 | return defineIndexV2(cozy, doctype, fields) 13 | } else { 14 | return defineIndexV3(cozy, doctype, fields) 15 | } 16 | }) 17 | } 18 | 19 | export function query(cozy, indexRef, options) { 20 | return cozy.isV2().then(isV2 => { 21 | if (!indexRef) { 22 | throw new Error('query should be passed the indexRef') 23 | } 24 | if (isV2) { 25 | return queryV2(cozy, indexRef, options) 26 | } else { 27 | return queryV3(cozy, indexRef, options) 28 | } 29 | }) 30 | } 31 | 32 | export function queryFiles(cozy, indexRef, options) { 33 | const opts = getV3Options(indexRef, options) 34 | return cozyFetchRawJSON(cozy, 'POST', '/files/_find', opts).then( 35 | response => (options.wholeResponse ? response : response.docs) 36 | ) 37 | } 38 | 39 | // Internals 40 | 41 | const VALUEOPERATORS = ['$eq', '$gt', '$gte', '$lt', '$lte'] 42 | const LOGICOPERATORS = ['$or', '$and', '$not'] 43 | 44 | /* eslint-disable */ 45 | const MAP_TEMPLATE = function(doc) { 46 | if (doc.docType.toLowerCase() === 'DOCTYPEPLACEHOLDER') { 47 | emit(FIELDSPLACEHOLDER, doc) 48 | } 49 | } 50 | .toString() 51 | .replace(/ /g, '') 52 | .replace(/\n/g, '') 53 | const COUCHDB_INFINITY = { '\uFFFF': '\uFFFF' } 54 | const COUCHDB_LOWEST = null 55 | /* eslint-enable */ 56 | 57 | // defineIndexV2 is equivalent to defineIndex but only works for V2. 58 | // It transforms the index fields into a map reduce view. 59 | function defineIndexV2(cozy, doctype, fields) { 60 | let indexName = 'by' + fields.map(capitalize).join('') 61 | let indexDefinition = { 62 | map: makeMapFunction(doctype, fields), 63 | reduce: '_count' 64 | } 65 | let path = `/request/${doctype}/${indexName}/` 66 | return cozyFetchJSON(cozy, 'PUT', path, indexDefinition).then(() => ({ 67 | doctype: doctype, 68 | type: 'mapreduce', 69 | name: indexName, 70 | fields: fields 71 | })) 72 | } 73 | 74 | function defineIndexV3(cozy, doctype, fields) { 75 | let path = createPath(cozy, false, doctype, '_index') 76 | let indexDefinition = { index: { fields } } 77 | return cozyFetchJSON(cozy, 'POST', path, indexDefinition).then(response => { 78 | const indexResult = { 79 | doctype: doctype, 80 | type: 'mango', 81 | name: response.id, 82 | fields 83 | } 84 | 85 | if (response.result === 'exists') return indexResult 86 | 87 | // indexes might not be usable right after being created; so we delay the resolving until they are 88 | const selector = {} 89 | selector[fields[0]] = { $gt: null } 90 | 91 | const opts = getV3Options(indexResult, { selector: selector }) 92 | let path = createPath(cozy, false, indexResult.doctype, '_find') 93 | return cozyFetchJSON(cozy, 'POST', path, opts) 94 | .then(() => indexResult) 95 | .catch(() => { 96 | // one retry 97 | return sleep(1000) 98 | .then(() => cozyFetchJSON(cozy, 'POST', path, opts)) 99 | .then(() => indexResult) 100 | .catch(() => { 101 | return sleep(500).then(() => indexResult) 102 | }) 103 | }) 104 | }) 105 | } 106 | 107 | // queryV2 is equivalent to query but only works for V2. 108 | // It transforms the query into a _views call using makeMapReduceQuery 109 | function queryV2(cozy, indexRef, options) { 110 | if (indexRef.type !== 'mapreduce') { 111 | throw new Error( 112 | 'query indexRef should be the return value of defineIndexV2' 113 | ) 114 | } 115 | if (options.fields) { 116 | warn('query fields will be ignored on v2') 117 | } 118 | 119 | let path = `/request/${indexRef.doctype}/${indexRef.name}/` 120 | let opts = makeMapReduceQuery(indexRef, options) 121 | return cozyFetchJSON(cozy, 'POST', path, opts).then(response => 122 | response.map(r => r.value) 123 | ) 124 | } 125 | 126 | // queryV3 is equivalent to query but only works for V3 127 | function queryV3(cozy, indexRef, options) { 128 | const opts = getV3Options(indexRef, options) 129 | 130 | let path = createPath(cozy, false, indexRef.doctype, '_find') 131 | return cozyFetchJSON(cozy, 'POST', path, opts).then( 132 | response => (options.wholeResponse ? response : response.docs) 133 | ) 134 | } 135 | 136 | function getV3Options(indexRef, options) { 137 | if (indexRef.type !== 'mango') { 138 | throw new Error('indexRef should be the return value of defineIndexV3') 139 | } 140 | 141 | let opts = { 142 | use_index: indexRef.name, 143 | fields: options.fields, 144 | selector: options.selector, 145 | limit: options.limit, 146 | skip: options.skip, 147 | since: options.since, 148 | sort: options.sort 149 | } 150 | 151 | if (options.descending) { 152 | opts.sort = indexRef.fields.map(f => ({ [f]: 'desc' })) 153 | } 154 | 155 | return opts 156 | } 157 | 158 | // misc 159 | function capitalize(name) { 160 | return name.charAt(0).toUpperCase() + name.slice(1) 161 | } 162 | 163 | function makeMapFunction(doctype, fields) { 164 | fields = '[' + fields.map(name => 'doc.' + name).join(',') + ']' 165 | 166 | return MAP_TEMPLATE.replace( 167 | 'DOCTYPEPLACEHOLDER', 168 | doctype.toLowerCase() 169 | ).replace('FIELDSPLACEHOLDER', fields) 170 | } 171 | 172 | // parseSelector takes a mango selector and returns it as an array of filter 173 | // a filter is [path, operator, value] array 174 | // a path is an array of field names 175 | // This function is only exported so it can be unit tested. 176 | // Example : 177 | // parseSelector({"test":{"deep": {"$gt": 3}}}) 178 | // [[['test', 'deep'], '$gt', 3 ]] 179 | export function parseSelector(selector, path = [], operator = '$eq') { 180 | if (typeof selector !== 'object') { 181 | return [[path, operator, selector]] 182 | } 183 | 184 | let keys = Object.keys(selector) 185 | if (keys.length === 0) { 186 | throw new Error('empty selector') 187 | } else { 188 | return keys.reduce(function(acc, k) { 189 | if (LOGICOPERATORS.indexOf(k) !== -1) { 190 | throw new Error('cozy-client-js does not support mango logic ops') 191 | } else if (VALUEOPERATORS.indexOf(k) !== -1) { 192 | return acc.concat(parseSelector(selector[k], path, k)) 193 | } else { 194 | return acc.concat(parseSelector(selector[k], path.concat(k), '$eq')) 195 | } 196 | }, []) 197 | } 198 | } 199 | 200 | // normalizeSelector takes a mango selector and returns it as an object 201 | // normalized. 202 | // This function is only exported so it can be unit tested. 203 | // Example : 204 | // parseSelector({"test":{"deep": {"$gt": 3}}}) 205 | // {"test.deep": {"$gt": 3}} 206 | export function normalizeSelector(selector) { 207 | var filters = parseSelector(selector) 208 | return filters.reduce(function(acc, filter) { 209 | let [path, op, value] = filter 210 | let field = path.join('.') 211 | acc[field] = acc[field] || {} 212 | acc[field][op] = value 213 | return acc 214 | }, {}) 215 | } 216 | 217 | // applySelector takes the normalized selector for the current field 218 | // and append the proper values to opts.startkey, opts.endkey 219 | function applySelector(selector, opts) { 220 | let value = selector['$eq'] 221 | let lower = COUCHDB_LOWEST 222 | let upper = COUCHDB_INFINITY 223 | let inclusiveEnd 224 | 225 | if (value) { 226 | opts.startkey.push(value) 227 | opts.endkey.push(value) 228 | return false 229 | } 230 | 231 | value = selector['$gt'] 232 | if (value) { 233 | throw new Error('operator $gt (strict greater than) not supported') 234 | } 235 | 236 | value = selector['$gte'] 237 | if (value) { 238 | lower = value 239 | } 240 | 241 | value = selector['$lte'] 242 | if (value) { 243 | upper = value 244 | inclusiveEnd = true 245 | } 246 | 247 | value = selector['$lt'] 248 | if (value) { 249 | upper = value 250 | inclusiveEnd = false 251 | } 252 | 253 | opts.startkey.push(lower) 254 | opts.endkey.push(upper) 255 | if (inclusiveEnd !== undefined) opts.inclusive_end = inclusiveEnd 256 | return true 257 | } 258 | 259 | // makeMapReduceQuery takes a mango query and generate _views call parameters 260 | // to obtain same results depending on fields in the passed indexRef. 261 | export function makeMapReduceQuery(indexRef, query) { 262 | let mrquery = { 263 | startkey: [], 264 | endkey: [], 265 | reduce: false 266 | } 267 | let firstFreeValueField = null 268 | let normalizedSelector = normalizeSelector(query.selector) 269 | 270 | indexRef.fields.forEach(function(field) { 271 | let selector = normalizedSelector[field] 272 | 273 | if (selector && firstFreeValueField != null) { 274 | throw new Error( 275 | 'Selector on field ' + 276 | field + 277 | ', but not on ' + 278 | firstFreeValueField + 279 | ' which is higher in index fields.' 280 | ) 281 | } else if (selector) { 282 | selector.used = true 283 | let isFreeValue = applySelector(selector, mrquery) 284 | if (isFreeValue) firstFreeValueField = field 285 | } else if (firstFreeValueField == null) { 286 | firstFreeValueField = field 287 | mrquery.endkey.push(COUCHDB_INFINITY) 288 | } 289 | }) 290 | 291 | Object.keys(normalizedSelector).forEach(function(field) { 292 | if (!normalizedSelector[field].used) { 293 | throw new Error( 294 | 'Cant apply selector on ' + field + ', it is not in index' 295 | ) 296 | } 297 | }) 298 | 299 | if (query.descending) { 300 | mrquery = { 301 | descending: true, 302 | reduce: false, 303 | startkey: mrquery.endkey, 304 | endkey: mrquery.startkey, 305 | inclusive_end: mrquery.inclusive_end 306 | } 307 | } 308 | 309 | return mrquery 310 | } 311 | -------------------------------------------------------------------------------- /src/offline.js: -------------------------------------------------------------------------------- 1 | // Define global `fetch` so it's made available to `pouchdb-browser` 2 | import 'cross-fetch/polyfill' 3 | 4 | import { DOCTYPE_FILES } from './doctypes' 5 | import { refreshToken } from './auth_v3' 6 | import { isOffline } from './utils' 7 | import PouchDB from 'pouchdb-browser' 8 | import pouchdbFind from 'pouchdb-find' 9 | 10 | export const replicationOfflineError = 11 | 'Replication abort, your device is actually offline.' 12 | 13 | let pluginLoaded = false 14 | 15 | /* 16 | For each doctype we have some parameters: 17 | cozy._offline[doctype] = { 18 | database: pouchdb database 19 | replication: the pouchdb replication 20 | replicationPromise: promise of replication 21 | interval: repeated replication interval 22 | } 23 | */ 24 | 25 | export function init(cozy, { options = {}, doctypes = [] }) { 26 | for (let doctype of doctypes) { 27 | createDatabase(cozy, doctype, options) 28 | } 29 | } 30 | 31 | // helper 32 | 33 | function getInfo(cozy, doctype) { 34 | cozy._offline = cozy._offline || [] 35 | cozy._offline[doctype] = cozy._offline[doctype] || {} 36 | return cozy._offline[doctype] 37 | } 38 | 39 | export function getDoctypes(cozy) { 40 | cozy._offline = cozy._offline || [] 41 | return Object.keys(cozy._offline) 42 | } 43 | 44 | // 45 | // DATABASE 46 | // 47 | 48 | export function hasDatabase(cozy, doctype) { 49 | return getDatabase(cozy, doctype) !== undefined 50 | } 51 | 52 | export function getDatabase(cozy, doctype) { 53 | return getInfo(cozy, doctype).database 54 | } 55 | 56 | export function setDatabase(cozy, doctype, database) { 57 | cozy._offline[doctype].database = database 58 | return getDatabase(cozy, doctype) 59 | } 60 | 61 | export function migrateDatabase(cozy, doctype, options = {}) { 62 | const oldDb = getDatabase(cozy, doctype) 63 | const newOptions = { 64 | adapter: 'idb', 65 | ...options 66 | } 67 | const newDb = new PouchDB(doctype, newOptions) 68 | 69 | return oldDb.replicate.to(newDb).then(() => { 70 | setDatabase(cozy, doctype, newDb) 71 | oldDb.destroy() 72 | return newDb 73 | }) 74 | } 75 | 76 | export function createDatabase(cozy, doctype, options = {}) { 77 | if (!pluginLoaded) { 78 | PouchDB.plugin(pouchdbFind) 79 | pluginLoaded = true 80 | } 81 | 82 | if (hasDatabase(cozy, doctype)) { 83 | return Promise.resolve(getDatabase(cozy, doctype)) 84 | } 85 | 86 | setDatabase(cozy, doctype, new PouchDB(doctype, options)) 87 | return createIndexes(cozy, doctype).then(() => getDatabase(cozy, doctype)) 88 | } 89 | 90 | export function destroyDatabase(cozy, doctype) { 91 | if (!hasDatabase(cozy, doctype)) { 92 | return Promise.resolve(false) 93 | } 94 | 95 | return stopRepeatedReplication(cozy, doctype) 96 | .then(() => stopReplication(cozy, doctype)) 97 | .then(() => getDatabase(cozy, doctype).destroy()) 98 | .then(response => { 99 | setDatabase(cozy, doctype, undefined) 100 | return response 101 | }) 102 | } 103 | 104 | export function destroyAllDatabase(cozy) { 105 | const doctypes = getDoctypes(cozy) 106 | const destroy = doctype => destroyDatabase(cozy, doctype) 107 | return Promise.all(doctypes.map(destroy)) 108 | } 109 | 110 | function createIndexes(cozy, doctype) { 111 | if (doctype === DOCTYPE_FILES) { 112 | return getDatabase(cozy, doctype).createIndex({ 113 | index: { fields: ['dir_id'] } 114 | }) 115 | } 116 | return Promise.resolve() 117 | } 118 | 119 | // 120 | // REPLICATION 121 | // 122 | 123 | export function hasReplication(cozy, doctype) { 124 | return getReplication(cozy, doctype) !== undefined 125 | } 126 | 127 | function getReplication(cozy, doctype) { 128 | return getInfo(cozy, doctype).replication 129 | } 130 | 131 | function setReplication(cozy, doctype, replication) { 132 | cozy._offline[doctype].replication = replication 133 | return getReplication(cozy, doctype) 134 | } 135 | 136 | function getReplicationUrl(cozy, doctype) { 137 | return cozy.authorize().then(credentials => { 138 | const basic = credentials.token.toBasicAuth() 139 | return (cozy._url + '/data/' + doctype).replace('//', `//${basic}`) 140 | }) 141 | } 142 | 143 | function getReplicationPromise(cozy, doctype) { 144 | return getInfo(cozy, doctype).replicationPromise 145 | } 146 | 147 | function setReplicationPromise(cozy, doctype, promise) { 148 | cozy._offline[doctype].replicationPromise = promise 149 | return getReplicationPromise(cozy, doctype) 150 | } 151 | 152 | export function replicateFromCozy(cozy, doctype, options = {}) { 153 | return setReplicationPromise( 154 | cozy, 155 | doctype, 156 | new Promise((resolve, reject) => { 157 | if (!hasDatabase(cozy, doctype)) { 158 | createDatabase(cozy, doctype) 159 | } 160 | if (options.live === true) { 161 | return reject( 162 | new Error("You can't use `live` option with Cozy couchdb.") 163 | ) 164 | } 165 | 166 | if (isOffline()) { 167 | reject(replicationOfflineError) 168 | options.onError && options.onError(replicationOfflineError) 169 | return 170 | } 171 | 172 | getReplicationUrl(cozy, doctype).then(url => 173 | setReplication( 174 | cozy, 175 | doctype, 176 | getDatabase(cozy, doctype) 177 | .replicate.from(url, options) 178 | .on('complete', info => { 179 | setReplication(cozy, doctype, undefined) 180 | resolve(info) 181 | options.onComplete && options.onComplete(info) 182 | }) 183 | .on('error', err => { 184 | if (/Expired token/.test(err.error)) { 185 | cozy.authorize().then(({ client, token }) => { 186 | refreshToken(cozy, client, token) 187 | .then(newToken => cozy.saveCredentials(client, newToken)) 188 | .then(() => replicateFromCozy(cozy, doctype, options)) 189 | }) 190 | } else { 191 | console.warn(`ReplicateFromCozy '${doctype}' Error:`) 192 | console.warn(err) 193 | setReplication(cozy, doctype, undefined) 194 | reject(err) 195 | options.onError && options.onError(err) 196 | } 197 | }) 198 | ) 199 | ) 200 | }) 201 | ) 202 | } 203 | 204 | export function stopReplication(cozy, doctype) { 205 | if (!getDatabase(cozy, doctype) || !hasReplication(cozy, doctype)) { 206 | return Promise.resolve() 207 | } 208 | 209 | return new Promise(resolve => { 210 | try { 211 | getReplicationPromise(cozy, doctype).then(() => { 212 | resolve() 213 | }) 214 | getReplication(cozy, doctype).cancel() 215 | // replication is set to undefined by complete replication 216 | } catch (e) { 217 | resolve() 218 | } 219 | }) 220 | } 221 | 222 | export function stopAllReplication(cozy) { 223 | const doctypes = getDoctypes(cozy) 224 | const stop = doctype => stopReplication(cozy, doctype) 225 | return Promise.all(doctypes.map(stop)) 226 | } 227 | 228 | // 229 | // REPEATED REPLICATION 230 | // 231 | 232 | function getRepeatedReplication(cozy, doctype) { 233 | return getInfo(cozy, doctype).interval 234 | } 235 | 236 | function setRepeatedReplication(cozy, doctype, interval) { 237 | cozy._offline[doctype].interval = interval 238 | } 239 | 240 | export function hasRepeatedReplication(cozy, doctype) { 241 | return getRepeatedReplication(cozy, doctype) !== undefined 242 | } 243 | 244 | export function startRepeatedReplication(cozy, doctype, timer, options = {}) { 245 | // TODO: add timer limitation for not flooding Gozy 246 | if (hasRepeatedReplication(cozy, doctype)) { 247 | return getRepeatedReplication(cozy, doctype) 248 | } 249 | 250 | return setRepeatedReplication( 251 | cozy, 252 | doctype, 253 | setInterval(() => { 254 | if (isOffline()) { 255 | // network is offline, replication cannot be launched 256 | console.info(replicationOfflineError) 257 | return 258 | } 259 | if (!hasReplication(cozy, doctype)) { 260 | replicateFromCozy(cozy, doctype, options) 261 | // TODO: add replicationToCozy 262 | } 263 | }, timer * 1000) 264 | ) 265 | } 266 | 267 | export function stopRepeatedReplication(cozy, doctype) { 268 | if (hasRepeatedReplication(cozy, doctype)) { 269 | clearInterval(getRepeatedReplication(cozy, doctype)) 270 | setRepeatedReplication(cozy, doctype, undefined) 271 | } 272 | if (hasReplication(cozy, doctype)) { 273 | return stopReplication(cozy, doctype) 274 | } 275 | 276 | return Promise.resolve() 277 | } 278 | 279 | export function stopAllRepeatedReplication(cozy) { 280 | const doctypes = getDoctypes(cozy) 281 | const stop = doctype => stopRepeatedReplication(cozy, doctype) 282 | return Promise.all(doctypes.map(stop)) 283 | } 284 | -------------------------------------------------------------------------------- /src/relations.js: -------------------------------------------------------------------------------- 1 | import { cozyFetchJSON, cozyFetchRawJSON } from './fetch' 2 | import { DOCTYPE_FILES } from './doctypes' 3 | 4 | function updateRelations(verb) { 5 | return function(cozy, doc, ids) { 6 | if (!doc) throw new Error('missing doc argument') 7 | if (!Array.isArray(ids)) ids = [ids] 8 | 9 | const refs = ids.map(id => ({ type: DOCTYPE_FILES, id })) 10 | 11 | return cozyFetchJSON(cozy, verb, makeReferencesPath(doc), { data: refs }) 12 | } 13 | } 14 | 15 | export const addReferencedFiles = updateRelations('POST') 16 | export const removeReferencedFiles = updateRelations('DELETE') 17 | 18 | export function listReferencedFiles(cozy, doc) { 19 | if (!doc) throw new Error('missing doc argument') 20 | return cozyFetchJSON(cozy, 'GET', makeReferencesPath(doc)).then(files => 21 | files.map(file => file._id) 22 | ) 23 | } 24 | 25 | export function fetchReferencedFiles(cozy, doc, options, sort) { 26 | if (!doc) throw new Error('missing doc argument') 27 | const params = Object.keys(options) 28 | .map(key => { 29 | const value = encodeURIComponent(JSON.stringify(options[key])) 30 | return `&page[${key}]=${value}` 31 | }) 32 | .join('') 33 | // Datetime is the default sort, but 'id' is also available 34 | if (!sort) { 35 | sort = 'datetime' 36 | } 37 | return cozyFetchRawJSON( 38 | cozy, 39 | 'GET', 40 | `${makeReferencesPath(doc)}?include=files&sort=${sort}${params}` 41 | ) 42 | } 43 | 44 | function makeReferencesPath(doc) { 45 | const type = encodeURIComponent(doc._type) 46 | const id = encodeURIComponent(doc._id) 47 | return `/data/${type}/${id}/relationships/references` 48 | } 49 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | import { cozyFetchJSON } from './fetch' 2 | 3 | export function diskUsage(cozy) { 4 | return cozyFetchJSON(cozy, 'GET', `/settings/disk-usage`) 5 | } 6 | 7 | export function changePassphrase(cozy, currentPassPhrase, newPassPhrase) { 8 | return cozyFetchJSON(cozy, 'PUT', `/settings/passphrase`, { 9 | current_passphrase: currentPassPhrase, 10 | new_passphrase: newPassPhrase 11 | }) 12 | } 13 | 14 | export function getInstance(cozy) { 15 | return cozyFetchJSON(cozy, 'GET', `/settings/instance`) 16 | } 17 | 18 | export function updateInstance(cozy, instance) { 19 | return cozyFetchJSON(cozy, 'PUT', `/settings/instance`, instance) 20 | } 21 | 22 | export function getClients(cozy) { 23 | return cozyFetchJSON(cozy, 'GET', `/settings/clients`) 24 | } 25 | 26 | export function deleteClientById(cozy, id) { 27 | return cozyFetchJSON(cozy, 'DELETE', `/settings/clients/${id}`) 28 | } 29 | 30 | export function updateLastSync(cozy) { 31 | return cozyFetchJSON(cozy, 'POST', '/settings/synchronized') 32 | } 33 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* global navigator */ 2 | const FuzzFactor = 0.3 3 | 4 | export function unpromiser(fn) { 5 | return function(...args) { 6 | const value = fn.apply(this, args) 7 | if (!isPromise(value)) { 8 | return value 9 | } 10 | const l = args.length 11 | if (l === 0 || typeof args[l - 1] !== 'function') { 12 | return 13 | } 14 | const cb = args[l - 1] 15 | value.then(res => cb(null, res), err => cb(err, null)) 16 | } 17 | } 18 | 19 | export function isPromise(value) { 20 | return !!value && typeof value.then === 'function' 21 | } 22 | 23 | export function isOnline() { 24 | return typeof navigator !== 'undefined' ? navigator.onLine : true 25 | } 26 | 27 | export function isOffline() { 28 | return !isOnline() 29 | } 30 | 31 | export function sleep(time, args) { 32 | return new Promise(resolve => { 33 | setTimeout(resolve, time, args) 34 | }) 35 | } 36 | 37 | export function retry(fn, count, delay = 300) { 38 | return function doTry(...args) { 39 | return fn(...args).catch(err => { 40 | if (--count < 0) { 41 | throw err 42 | } 43 | return sleep(getBackedoffDelay(delay, count)).then(() => doTry(...args)) 44 | }) 45 | } 46 | } 47 | 48 | export function getFuzzedDelay(retryDelay) { 49 | const fuzzingFactor = (Math.random() * 2 - 1) * FuzzFactor 50 | return retryDelay * (1.0 + fuzzingFactor) 51 | } 52 | 53 | export function getBackedoffDelay(retryDelay, retryCount = 1) { 54 | return getFuzzedDelay(retryDelay * Math.pow(2, retryCount - 1)) 55 | } 56 | 57 | export function createPath(cozy, isV2, doctype, id = '', query = null) { 58 | let route = '/data/' 59 | if (!isV2) { 60 | route += `${encodeURIComponent(doctype)}/` 61 | } 62 | if (id !== '') { 63 | route += encodeURIComponent(id) 64 | } 65 | const q = encodeQuery(query) 66 | if (q !== '') { 67 | route += '?' + q 68 | } 69 | return route 70 | } 71 | 72 | export function encodeQuery(query) { 73 | if (!query) { 74 | return '' 75 | } 76 | let q = '' 77 | for (const qname in query) { 78 | if (q !== '') { 79 | q += '&' 80 | } 81 | q += `${encodeURIComponent(qname)}=${encodeURIComponent(query[qname])}` 82 | } 83 | return q 84 | } 85 | 86 | export function decodeQuery(url) { 87 | let queryIndex = url.indexOf('?') 88 | if (queryIndex < 0) { 89 | queryIndex = url.length 90 | } 91 | const queries = {} 92 | let fragIndex = url.indexOf('#') 93 | if (fragIndex < 0) { 94 | fragIndex = url.length 95 | } 96 | if (fragIndex < queryIndex) { 97 | return queries 98 | } 99 | const queryStr = url.slice(queryIndex + 1, fragIndex) 100 | if (queryStr === '') { 101 | return queries 102 | } 103 | const parts = queryStr.split('&') 104 | for (let i = 0; i < parts.length; i++) { 105 | let pair = parts[i].split('=') 106 | if (pair.length === 0 || pair[0] === '') { 107 | continue 108 | } 109 | const qname = decodeURIComponent(pair[0]) 110 | if (queries.hasOwnProperty(qname)) { 111 | continue 112 | } 113 | if (pair.length === 1) { 114 | queries[qname] = true 115 | } else if (pair.length === 2) { 116 | queries[qname] = decodeURIComponent(pair[1]) 117 | } else { 118 | throw new Error('Malformed URL') 119 | } 120 | } 121 | return queries 122 | } 123 | 124 | const warned = [] 125 | export function warn(text) { 126 | if (warned.indexOf(text) === -1) { 127 | warned.push(text) 128 | console.warn('cozy-client-js', text) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | export function randomGenerator(seed = Math.random()) { 2 | let fn = function rand() { 3 | var x = Math.sin(seed++) * 10000 4 | return x - Math.floor(x) 5 | } 6 | fn.seed = seed 7 | return fn 8 | } 9 | -------------------------------------------------------------------------------- /test/integration/auth.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client, MemoryStorage } from '../../src' 7 | 8 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 9 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 10 | 11 | describe('oauth API', async function() { 12 | let client, oauthClient 13 | 14 | beforeEach(function() { 15 | if (COZY_STACK_VERSION === '2') { 16 | this.skip() 17 | } 18 | }) 19 | 20 | it('Register a client', async function() { 21 | client = new Client({ 22 | cozyURL: COZY_STACK_URL, 23 | oauth: { 24 | storage: new MemoryStorage(), 25 | clientParams: { 26 | redirectURI: 'http://localhost:3333/oauth/callback', 27 | softwareID: 'cozy-client-js', 28 | clientName: 'integration-test', 29 | scopes: ['io.cozy.files'] 30 | } 31 | } 32 | }) 33 | oauthClient = await client.auth.registerClient() 34 | oauthClient.clientID.should.not.be.empty() 35 | oauthClient.clientSecret.should.not.be.empty() 36 | }) 37 | 38 | it('Update the client', async function() { 39 | oauthClient.clientKind = 'test' 40 | oauthClient = await client.auth.updateClient(oauthClient) 41 | oauthClient.clientID.should.not.be.empty() 42 | oauthClient.clientSecret.should.not.be.empty() 43 | oauthClient.clientKind.should.equal('test') 44 | }) 45 | 46 | it('Get the client', async function() { 47 | const infos = await client.auth.getClient(oauthClient) 48 | infos.clientID.should.not.be.empty() 49 | infos.clientSecret.should.not.be.empty() 50 | infos.clientKind.should.equal('test') 51 | }) 52 | 53 | it('Unregister the client', async function() { 54 | await client.auth.unregisterClient(oauthClient) 55 | let err 56 | try { 57 | await client.auth.getClient(oauthClient) 58 | } catch (e) { 59 | err = e 60 | } 61 | err.should.not.be.null() 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/integration/data.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | import mockTokenRetrieve from '../mock-iframe-token' 8 | 9 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 10 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 11 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 12 | 13 | describe('data API', function() { 14 | let docID = null 15 | let docRev = null 16 | const cozy = {} 17 | 18 | if (COZY_STACK_VERSION === '2') { 19 | before(mockTokenRetrieve) 20 | } 21 | 22 | beforeEach(() => { 23 | cozy.client = new Client({ 24 | cozyURL: COZY_STACK_URL, 25 | token: COZY_STACK_TOKEN 26 | }) 27 | }) 28 | 29 | describe('Create document', function() { 30 | it('Works', async function() { 31 | const testDoc = { 32 | test: 'value' 33 | } 34 | const created = await cozy.client.data.create( 35 | 'io.cozy.testobject', 36 | testDoc 37 | ) 38 | created.should.have.property('_id') 39 | created.should.have.property('_rev') 40 | created.should.have.property('test', 'value') 41 | docID = created._id 42 | docRev = created._rev 43 | }) 44 | }) 45 | 46 | describe('Fetch document', function() { 47 | it('Works', async function() { 48 | let fetched = await cozy.client.data.find('io.cozy.testobject', docID) 49 | fetched.should.have.property('_id', docID) 50 | fetched.should.have.property('_rev', docRev) 51 | fetched.should.have.property('test', 'value') 52 | }) 53 | }) 54 | 55 | describe('Fetch multiple documents', function() { 56 | const missingID = 'missing' 57 | 58 | beforeEach(function() { 59 | if (COZY_STACK_VERSION === '2') { 60 | this.skip() 61 | } 62 | }) 63 | 64 | it('Works when some of the documents exist', async function() { 65 | const resultsById = await cozy.client.data.findMany( 66 | 'io.cozy.testobject', 67 | [docID, missingID] 68 | ) 69 | resultsById.should.have.properties([docID, missingID]) 70 | resultsById[docID].should.deepEqual({ 71 | doc: { 72 | _id: docID, 73 | _rev: docRev, 74 | test: 'value' 75 | } 76 | }) 77 | resultsById[missingID].should.deepEqual({ 78 | error: 'not_found' 79 | }) 80 | }) 81 | 82 | it('Works when the database does not exist yet', async function() { 83 | const resultsById = await cozy.client.data.findMany( 84 | 'io.cozy.idonotexist', 85 | [missingID] 86 | ) 87 | resultsById[missingID].error.status.should.equal(404) 88 | }) 89 | }) 90 | 91 | describe('Fetch all documents', function() { 92 | beforeEach(function() { 93 | if (COZY_STACK_VERSION === '2') { 94 | this.skip() 95 | } 96 | }) 97 | 98 | it('Works correctly', async function() { 99 | const docs = await cozy.client.data.findAll('io.cozy.testobject') 100 | should(docs.length).equal(1) 101 | docs[0].should.deepEqual({ 102 | _id: docID, 103 | _rev: docRev, 104 | test: 'value' 105 | }) 106 | }) 107 | 108 | it('Returns an empty array when the database does not exist yet', async function() { 109 | const docs = await cozy.client.data.findAll('io.cozy.idonotexist') 110 | should(docs.length).equal(0) 111 | }) 112 | }) 113 | 114 | describe('Update document', function() { 115 | it('Works', async function() { 116 | const changes = { 117 | test: 'value2' 118 | } 119 | const updated = await cozy.client.data.update( 120 | 'io.cozy.testobject', 121 | { _id: docID, _rev: docRev }, 122 | changes 123 | ) 124 | updated.should.have.property('_id', docID) 125 | updated.should.have.property('_rev') 126 | updated.should.have.property('test', 'value2') 127 | docID = updated._id 128 | docRev = updated._rev 129 | }) 130 | }) 131 | 132 | describe('Delete document', function() { 133 | it('Works', async function() { 134 | const deleted = await cozy.client.data.delete('io.cozy.testobject', { 135 | _id: docID, 136 | _rev: docRev 137 | }) 138 | deleted.should.have.property('id', docID) 139 | deleted.should.have.property('rev') 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /test/integration/files.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global fetch */ 3 | 4 | // eslint-disable-next-line no-unused-vars 5 | import should from 'should' 6 | import 'cross-fetch/polyfill' 7 | import { Readable } from 'stream' 8 | import { Client } from '../../src' 9 | import { randomGenerator } from '../helpers' 10 | 11 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 12 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 13 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 14 | 15 | describe('files API', async function() { 16 | this.timeout(5000) 17 | let random 18 | const cozy = {} 19 | 20 | beforeEach(function() { 21 | if (COZY_STACK_VERSION === '2') { 22 | this.skip() 23 | } 24 | cozy.client = new Client({ 25 | cozyURL: COZY_STACK_URL, 26 | token: COZY_STACK_TOKEN 27 | }) 28 | random = randomGenerator() 29 | }) 30 | 31 | it('creates a file', async function() { 32 | const filename = 'foo_' + random() 33 | const date = new Date('Wed, 01 Feb 2017 10:24:42 GMT') 34 | 35 | const created = await cozy.client.files.create('datastring1', { 36 | name: filename, 37 | contentType: 'application/json', 38 | lastModifiedDate: date 39 | }) 40 | 41 | created.should.have.property('attributes') 42 | created.attributes.md5sum.should.equal('7Zfd8PaeeXsm5WJesf/KJw==') 43 | new Date(created.attributes.created_at).should.eql(date) 44 | new Date(created.attributes.updated_at).should.eql(date) 45 | }) 46 | 47 | it('creates a file from a stream', async function() { 48 | const filename = 'foo_' + random() 49 | const stream = new Readable() 50 | 51 | stream.push('datastring1') 52 | stream.push(null) 53 | 54 | const created = await cozy.client.files.create(stream, { 55 | name: filename, 56 | contentType: 'application/json' 57 | }) 58 | 59 | created.should.have.property('attributes') 60 | created.attributes.md5sum.should.equal('7Zfd8PaeeXsm5WJesf/KJw==') 61 | }) 62 | 63 | it('fails if the contentLength is too small', async function() { 64 | const filename = 'foo_' + random() 65 | const stream = new Readable() 66 | 67 | stream.push('datastring1') 68 | stream.push(null) 69 | 70 | const created = await cozy.client.files.create(stream, { 71 | name: filename, 72 | contentType: 'application/json', 73 | contentLength: 4 74 | }) 75 | 76 | created.should.have.property('attributes') 77 | created.attributes.md5sum.should.equal('jXd/OF09/siBXSD3SWAm3A==') // md5 "data" 78 | }) 79 | 80 | it('fails if the contentLength is too big', async function() { 81 | const filename = 'foo_' + random() 82 | const stream = new Readable() 83 | this.timeout(5000) 84 | 85 | stream.push('datastring1') 86 | stream.push(null) 87 | // stream.timeout = 2000 88 | 89 | const oldFetch = global.fetch 90 | global.fetch = (url, opts) => { 91 | if (opts) opts.timeout = 500 92 | return oldFetch(url, opts) 93 | } 94 | after(() => { 95 | global.fetch = oldFetch 96 | }) 97 | 98 | await cozy.client.files 99 | .create(stream, { 100 | name: filename, 101 | contentType: 'application/json', 102 | contentLength: 20 103 | }) 104 | .should.be.rejected() 105 | }) 106 | 107 | it('updates a file', async function() { 108 | const filename = 'foo_' + random() 109 | 110 | const created = await cozy.client.files.create('datastring1', { 111 | name: filename 112 | }) 113 | created.should.have.property('attributes') 114 | 115 | const createdId = created._id 116 | 117 | const updated = await cozy.client.files.updateById(createdId, 'datastring2') 118 | updated.should.have.property('attributes') 119 | updated.attributes.md5sum.should.equal('iWpp8tcTP/DWTJSLf0hoyQ==') 120 | }) 121 | 122 | it('updates attributes', async function() { 123 | const filename = 'foo_' + random() 124 | 125 | const created = await cozy.client.files.create('datastring1', { 126 | name: filename 127 | }) 128 | created.should.have.property('attributes') 129 | 130 | const createdId = created._id 131 | 132 | const newname1 = 'newname1_' + random() 133 | const attrs1 = { tags: ['foo', 'bar'], name: newname1 } 134 | const updated1 = await cozy.client.files.updateAttributesById( 135 | createdId, 136 | attrs1 137 | ) 138 | updated1.should.have.property('attributes') 139 | updated1.attributes.name.should.startWith('newname1_') 140 | updated1.attributes.tags.should.eql(['foo', 'bar']) 141 | 142 | const newname2 = 'newname2_' + random() 143 | const attrs2 = { tags: ['foo'], name: newname2 } 144 | const updated2 = await cozy.client.files.updateAttributesByPath( 145 | '/' + newname1, 146 | attrs2 147 | ) 148 | updated2.should.have.property('attributes') 149 | updated2.attributes.name.should.startWith('newname2_') 150 | updated2.attributes.tags.should.eql(['foo']) 151 | }) 152 | 153 | it('creates directory', async function() { 154 | const dirname = 'foo_' + random() 155 | const date = new Date('Wed, 01 Feb 2017 10:24:42 GMT') 156 | 157 | const created = await cozy.client.files.createDirectory({ 158 | name: dirname, 159 | lastModifiedDate: date 160 | }) 161 | created.should.have.property('attributes') 162 | new Date(created.attributes.created_at).should.eql(date) 163 | new Date(created.attributes.updated_at).should.eql(date) 164 | }) 165 | 166 | it('gets directory info by ID', async function() { 167 | const dirname = 'foo_' + random() 168 | 169 | const created = await cozy.client.files.createDirectory({ name: dirname }) 170 | let directoryID = created._id 171 | 172 | const stats = await cozy.client.files.statById(directoryID) 173 | stats.should.have.property('attributes') 174 | }) 175 | 176 | it('gets directory info by Path', async function() { 177 | const dirname = 'foo_' + random() 178 | 179 | const created = await cozy.client.files.createDirectory({ name: dirname }) 180 | 181 | const stats = await cozy.client.files.statByPath('/' + dirname) 182 | stats.should.have.property('attributes') 183 | stats.should.have.property('_id', created._id) 184 | }) 185 | 186 | it('trashes file or directory', async function() { 187 | const dirname = 'foo_' + random() 188 | 189 | const created = await cozy.client.files.createDirectory({ name: dirname }) 190 | const createdId = created._id 191 | 192 | await cozy.client.files.trashById(createdId) 193 | }) 194 | 195 | it('downloads a file by path and id', async function() { 196 | const filename = 'foo_' + random() 197 | 198 | const created = await cozy.client.files.create('foo', { 199 | name: filename, 200 | contentType: 'application/json' 201 | }) 202 | 203 | const downloaded1 = await cozy.client.files.downloadById(created._id) 204 | const txt1 = await downloaded1.text() 205 | 206 | txt1.should.equal('foo') 207 | 208 | const downloaded2 = await cozy.client.files.downloadByPath('/' + filename) 209 | const txt2 = await downloaded2.text() 210 | 211 | txt2.should.equal('foo') 212 | }) 213 | 214 | it('destroy all trashed files and directories', async function() { 215 | await createTrashedDirectory(cozy.client, 'foo_' + random()) 216 | await createTrashedDirectory(cozy.client, 'foo_' + random()) 217 | await cozy.client.files.clearTrash() 218 | 219 | let trashed = await cozy.client.files.listTrash() 220 | trashed.should.be.an.Array() 221 | trashed.should.have.length(0) 222 | }) 223 | 224 | it('list trashed files and directories', async function() { 225 | await cozy.client.files.clearTrash() 226 | const created1 = await createTrashedDirectory( 227 | cozy.client, 228 | 'foo_' + random() 229 | ) 230 | const created2 = await createTrashedDirectory( 231 | cozy.client, 232 | 'foo_' + random() 233 | ) 234 | let trashed = await cozy.client.files.listTrash() 235 | trashed.should.be.an.Array() 236 | trashed.should.have.length(2) 237 | let found1 = false 238 | let found2 = false 239 | trashed.forEach(file => { 240 | if (file._id === created1._id) found1 = true 241 | else if (file._id === created2._id) found2 = true 242 | else throw new Error('found unexpected file' + file._id) 243 | }) 244 | found1.should.be.true 245 | found2.should.be.true 246 | }) 247 | 248 | it('restore a trashed file or directory', async function() { 249 | await cozy.client.files.clearTrash() 250 | const created = await createTrashedDirectory(cozy.client, 'foo_' + random()) 251 | await cozy.client.files.restoreById(created._id) 252 | }) 253 | 254 | it('destroy a trashed file or directory', async function() { 255 | await cozy.client.files.clearTrash() 256 | const created = await createTrashedDirectory(cozy.client, 'foo_' + random()) 257 | await cozy.client.files.destroyById(created._id) 258 | let trashed = await cozy.client.files.listTrash() 259 | trashed.should.be.an.Array() 260 | trashed.should.have.length(0) 261 | }) 262 | 263 | it('does not destroy a trashed file or directory with wrong rev', async function() { 264 | await cozy.client.files.clearTrash() 265 | const created = await cozy.client.files.create('datastring2', { 266 | name: 'foo_' + random(), 267 | contentType: 'application/json', 268 | lastModifiedDate: new Date('Wed, 01 Feb 2017 10:24:42 GMT') 269 | }) 270 | await cozy.client.files.trashById(created._id) 271 | await cozy.client.files 272 | .destroyById(created._id, { ifMatch: 'badbeef' }) 273 | .then( 274 | () => { 275 | throw new Error('should reject') 276 | }, 277 | err => { 278 | err.should.be.an.Error 279 | } 280 | ) 281 | const trashed = await cozy.client.files.listTrash() 282 | trashed.should.be.an.Array() 283 | trashed.should.have.length(1) 284 | await cozy.client.files.clearTrash() 285 | }) 286 | 287 | it('creates download link for 1 file by id and by path', async function() { 288 | const filename = 'foo_' + random() 289 | const created = await cozy.client.files.create('foo', { 290 | name: filename, 291 | contentType: 'application/json' 292 | }) 293 | 294 | const path = '/' + created.attributes.name 295 | let link1 = await cozy.client.files.getDownloadLinkByPath(path) 296 | let downloaded1 = await fetch(COZY_STACK_URL + link1) 297 | const txt1 = await downloaded1.text() 298 | txt1.should.equal('foo') 299 | 300 | let link2 = await cozy.client.files.getDownloadLinkById(created._id) 301 | let downloaded2 = await fetch(COZY_STACK_URL + link2) 302 | const txt2 = await downloaded2.text() 303 | txt2.should.equal('foo') 304 | }) 305 | 306 | it('creates download link for archive', async function() { 307 | const filename = 'foo_' + random() 308 | const created = await cozy.client.files.create('foo', { 309 | name: filename, 310 | contentType: 'application/json' 311 | }) 312 | 313 | const filename2 = 'bar_' + random() 314 | const created2 = await cozy.client.files.create('bar', { 315 | name: filename2, 316 | contentType: 'application/json' 317 | }) 318 | const toDownload = [ 319 | '/' + created.attributes.name, 320 | '/' + created2.attributes.name 321 | ] 322 | let link = await cozy.client.files.getArchiveLinkByPaths( 323 | toDownload, 324 | 'foobar' 325 | ) 326 | let downloaded = await fetch(COZY_STACK_URL + link) 327 | downloaded.ok.should.be.true 328 | downloaded.headers.get('Content-Type').should.equal('application/zip') 329 | const disp = downloaded.headers.get('Content-Disposition') 330 | disp.indexOf('foobar').should.not.equal(-1) 331 | }) 332 | 333 | describe('offline', async () => { 334 | beforeEach(() => { 335 | cozy.client = new Client({ 336 | cozyURL: COZY_STACK_URL, 337 | token: COZY_STACK_TOKEN, 338 | offline: { doctypes: ['io.cozy.files'], options: { adapter: 'memory' } } 339 | }) 340 | }) 341 | afterEach(() => { 342 | cozy.client.offline.destroyDatabase('io.cozy.files') 343 | }) 344 | 345 | it('and online should have same properties except for *links*', async () => { 346 | const folder = await createRandomDirectory(cozy.client) 347 | await cozy.client.offline.replicateFromCozy('io.cozy.files') 348 | const offline = await cozy.client.files.statById(folder._id) 349 | const online = await cozy.client.files.statById(folder._id, false) 350 | delete online.links 351 | online.should.have.properties(Object.keys(offline)) 352 | offline.should.have.properties(Object.keys(online)) 353 | offline.attributes.should.have.properties(Object.keys(online.attributes)) 354 | online.attributes.should.have.properties(Object.keys(offline.attributes)) 355 | }).timeout(4 * 1000) 356 | }) 357 | 358 | describe('share', () => { 359 | it('should get `sharecode` and `id` to create a share link', async () => { 360 | const document = await cozy.client.files.create('foo', { 361 | name: 'to be shared', 362 | contentType: 'application/json' 363 | }) 364 | const collection = await cozy.client.data.create( 365 | 'io.cozy.testreferencer', 366 | { 367 | name: 'foo_' + random() 368 | } 369 | ) 370 | 371 | await cozy.client.data.addReferencedFiles(collection, document._id) 372 | const data = await cozy.client.files.getCollectionShareLink( 373 | document._id, 374 | 'io.cozy.testreferencer' 375 | ) 376 | 377 | data.should.have.properties(['sharecode', 'id']) 378 | }) 379 | }) 380 | }) 381 | 382 | async function createTrashedDirectory(client, dirname) { 383 | const created = await client.files.createDirectory({ name: dirname }) 384 | await client.files.trashById(created._id) 385 | return created 386 | } 387 | 388 | async function createRandomDirectory(client) { 389 | const dirname = 'foo_' + randomGenerator()() 390 | const created = await client.files.createDirectory({ name: dirname }) 391 | return created 392 | } 393 | -------------------------------------------------------------------------------- /test/integration/jobs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | 8 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 9 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 10 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 11 | 12 | describe('jobs api', async function() { 13 | const cozy = {} 14 | 15 | beforeEach(function() { 16 | if (COZY_STACK_VERSION === '2') { 17 | this.skip() 18 | } 19 | cozy.client = new Client({ 20 | cozyURL: COZY_STACK_URL, 21 | token: COZY_STACK_TOKEN 22 | }) 23 | }) 24 | 25 | it('gets the number of jobs in a queue', async function() { 26 | const count = await cozy.client.jobs.count('log') 27 | count.should.equal(0) 28 | }) 29 | 30 | it('gets the jobs in a queue', async function() { 31 | const jobs = await cozy.client.jobs.queued('log') 32 | jobs.length.should.equal(0) 33 | }) 34 | 35 | it('enqueues a job', async function() { 36 | const created = await cozy.client.jobs.create( 37 | 'log', 38 | { foo: 'bar' }, 39 | { timeout: 2 } 40 | ) 41 | created.should.have.property('_id') 42 | created.should.have.property('attributes') 43 | const attrs = created.attributes 44 | attrs.worker.should.equal('log') 45 | attrs.state.should.equal('queued') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/integration/mango.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | import mockTokenRetrieve from '../mock-iframe-token' 8 | 9 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 10 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 11 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 12 | const DOCTYPE = 'io.cozy.testobject2' 13 | 14 | let docs = [ 15 | { group: 'A', path: '/a/b/c', year: 1984 }, 16 | { group: 'A', path: '/a', year: 1749 }, 17 | { group: 'A', path: '/a/z', year: 2104 }, 18 | { group: 'A', path: '/a/e', year: 1345 }, 19 | { group: 'B', path: '/a/b/d', year: 2104 } 20 | ] 21 | 22 | describe('mango API', function() { 23 | let indexOnGroup = null 24 | let indexOnGroupAndYear = null 25 | const cozy = {} 26 | 27 | if (COZY_STACK_VERSION === '2') { 28 | before(mockTokenRetrieve) 29 | } 30 | 31 | before(function() { 32 | cozy.client = new Client({ 33 | cozyURL: COZY_STACK_URL, 34 | token: COZY_STACK_TOKEN 35 | }) 36 | }) 37 | 38 | before(async function() { 39 | for (var i = 0, l = docs.length; i < l; i++) { 40 | docs[i] = await cozy.client.data.create(DOCTYPE, docs[i]) 41 | } 42 | }) 43 | 44 | after(async function() { 45 | for (var i = 0, l = docs.length; i < l; i++) { 46 | await cozy.client.data.delete(DOCTYPE, docs[i]) 47 | } 48 | }) 49 | 50 | it('Define indexOnGroup', async function() { 51 | indexOnGroup = await cozy.client.data.defineIndex(DOCTYPE, ['group']) 52 | }) 53 | 54 | it('Redefine the same index', async function() { 55 | await cozy.client.data.defineIndex(DOCTYPE, ['group']) 56 | // should.not.throw 57 | }) 58 | 59 | it('Define indexOnGroupAndYear', async function() { 60 | indexOnGroupAndYear = await cozy.client.data.defineIndex(DOCTYPE, [ 61 | 'group', 62 | 'year' 63 | ]) 64 | }) 65 | 66 | it('Query indexOnGroup', async function() { 67 | let results = await cozy.client.data.query(indexOnGroup, { 68 | selector: { group: 'A' } 69 | }) 70 | 71 | results.should.be.an.Array() 72 | results.should.have.length(4) 73 | }) 74 | 75 | it('Query indexOnGroupAndYear', async function() { 76 | let results = await cozy.client.data.query(indexOnGroupAndYear, { 77 | selector: { 78 | group: 'A', 79 | year: { $gt: 0 } 80 | } 81 | }) 82 | 83 | results.should.be.an.Array() 84 | results.should.have.length(4) 85 | }) 86 | 87 | it('Query indexOnGroupAndYear with 2 fields', async function() { 88 | let results = await cozy.client.data.query(indexOnGroupAndYear, { 89 | selector: { group: 'A', year: { $gte: 1900, $lt: 2200 } } 90 | }) 91 | 92 | results.should.be.an.Array() 93 | results.should.have.length(2) 94 | }) 95 | 96 | it('Query indexOnGroupAndYear with 2 fields and sorted', async function() { 97 | let sortedResults = await cozy.client.data.query(indexOnGroupAndYear, { 98 | selector: { group: 'A', year: { $gte: 1900, $lt: 2200 } }, 99 | descending: true 100 | }) 101 | let nonSortedresults = await cozy.client.data.query(indexOnGroupAndYear, { 102 | selector: { group: 'A', year: { $gte: 1900, $lt: 2200 } } 103 | }) 104 | 105 | sortedResults.should.be.an.Array() 106 | sortedResults.should.have.length(2) 107 | sortedResults[0].should.deepEqual(nonSortedresults[1]) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/integration/offline.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | import PouchDB from 'pouchdb-browser' 8 | import pouchdbFind from 'pouchdb-find' 9 | import { sleep } from '../../src/utils' 10 | PouchDB.plugin(require('pouchdb-adapter-memory')) 11 | 12 | // PouchDB should not be a mandatory dependency as it is only used in mobile 13 | // environment, so we declare it in global scope here. 14 | global.PouchDB = PouchDB 15 | global.pouchdbFind = pouchdbFind 16 | 17 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 18 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 19 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 20 | const DOCTYPE = 'io.cozy.testobject2' 21 | 22 | let docs = [ 23 | { group: 'A', path: '/a/b/c', year: 1984 }, 24 | { group: 'A', path: '/a', year: 1749 }, 25 | { group: 'A', path: '/a/z', year: 2104 }, 26 | { group: 'A', path: '/a/e', year: 1345 }, 27 | { group: 'B', path: '/a/b/d', year: 2104 } 28 | ] 29 | 30 | describe('offline', function() { 31 | const cozy = {} 32 | 33 | before(async function() { 34 | if (COZY_STACK_VERSION === '2') { 35 | return this.skip() 36 | } 37 | cozy.client = new Client({ 38 | cozyURL: COZY_STACK_URL, 39 | token: COZY_STACK_TOKEN 40 | }) 41 | docs = await Promise.all( 42 | docs.map(doc => cozy.client.data.create(DOCTYPE, doc)) 43 | ) 44 | }) 45 | 46 | after(async function() { 47 | if (COZY_STACK_VERSION === '3') { 48 | await docs.forEach(doc => cozy.client.data.delete(DOCTYPE, doc)) 49 | await cozy.client.offline.destroyAllDatabase() 50 | } 51 | }) 52 | 53 | it('can replicate database from cozy', async function() { 54 | await cozy.client.offline.createDatabase(DOCTYPE, { adapter: 'memory' }) 55 | let complete = await cozy.client.offline.replicateFromCozy(DOCTYPE) 56 | complete.docs_written.should.not.equal(0) 57 | complete = await cozy.client.offline.replicateFromCozy(DOCTYPE) 58 | return complete.docs_written.should.equal(0) 59 | }).timeout(3 * 1000) 60 | 61 | it("can't replicate with live option.", async function() { 62 | await cozy.client.offline.createDatabase(DOCTYPE, { adapter: 'memory' }) 63 | return cozy.client.offline 64 | .replicateFromCozy(DOCTYPE, { live: true }) 65 | .should.be.rejectedWith({ 66 | message: "You can't use `live` option with Cozy couchdb." 67 | }) 68 | }) 69 | 70 | it('can replicate created object in local database', async function() { 71 | // create a database 72 | const db = await cozy.client.offline.createDatabase(DOCTYPE, { 73 | adapter: 'memory' 74 | }) 75 | // create a doc 76 | const sampleDoc = { data: 'some Data' } 77 | const remoteDoc = await cozy.client.data.create(DOCTYPE, sampleDoc) 78 | // check the db to look for the new doc 79 | await db.get(remoteDoc._id).should.be.rejectedWith({ message: 'missing' }) 80 | // replicate the database 81 | await cozy.client.offline.replicateFromCozy(DOCTYPE) 82 | // doc should exist 83 | await db.get(remoteDoc._id).should.be.fulfilledWith({ 84 | _id: remoteDoc._id, 85 | _rev: remoteDoc._rev, 86 | data: remoteDoc.data 87 | }) 88 | // remove doc 89 | return cozy.client.data.delete(DOCTYPE, remoteDoc) 90 | }) 91 | 92 | it('can repeated replication and stop repeated replication', async function() { 93 | // create a database 94 | const db = await cozy.client.offline.createDatabase(DOCTYPE, { 95 | adapter: 'memory' 96 | }) 97 | // create a doc 98 | const sampleDoc = { data: 'some Data' } 99 | const remoteDoc = await cozy.client.data.create(DOCTYPE, sampleDoc) 100 | // check the db to look for the new doc 101 | db.get(remoteDoc._id).should.be.rejectedWith({ message: 'missing' }) 102 | // activate synchronisation x ms 103 | cozy.client.offline.hasRepeatedReplication(DOCTYPE).should.be.false 104 | cozy.client.offline.startRepeatedReplication(DOCTYPE, 0.5) 105 | // after a certain amount of time, doc should exist 106 | await sleep(1000) 107 | cozy.client.offline.hasRepeatedReplication(DOCTYPE).should.be.true 108 | await cozy.client.offline.stopRepeatedReplication() 109 | cozy.client.offline.hasRepeatedReplication(DOCTYPE).should.be.false 110 | // create another doc after sync 111 | const anotherDoc = { data: 'some other Data' } 112 | const anotherRemoteDoc = await cozy.client.data.create(DOCTYPE, anotherDoc) 113 | const promises = [] 114 | promises.push( 115 | db.get(remoteDoc._id).should.be.fulfilledWith({ 116 | _id: remoteDoc._id, 117 | _rev: remoteDoc._rev, 118 | data: remoteDoc.data 119 | }) 120 | ) 121 | promises.push( 122 | db 123 | .get(anotherRemoteDoc._id) 124 | .should.be.rejectedWith({ message: 'missing', status: 404 }) 125 | ) 126 | // remove docs 127 | cozy.client.data.delete(DOCTYPE, remoteDoc) 128 | cozy.client.data.delete(DOCTYPE, anotherRemoteDoc) 129 | return Promise.all(promises) 130 | }).timeout(4000) 131 | }) 132 | -------------------------------------------------------------------------------- /test/integration/relations.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | import { randomGenerator } from '../helpers' 8 | 9 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 10 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 11 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 12 | 13 | async function createRandomFile(cozy, random) { 14 | return cozy.client.files.create('datastring1', { 15 | name: 'foo_' + random(), 16 | contentType: 'application/json' 17 | }) 18 | } 19 | 20 | describe('references', async function() { 21 | let random 22 | const cozy = {} 23 | let specialDoc 24 | let ids = [] 25 | 26 | before(async function() { 27 | if (COZY_STACK_VERSION === '2') { 28 | this.skip() 29 | } 30 | cozy.client = new Client({ 31 | cozyURL: COZY_STACK_URL, 32 | token: COZY_STACK_TOKEN 33 | }) 34 | random = randomGenerator() 35 | for (let i = 0; i < 3; i++) { 36 | let file = await createRandomFile(cozy, random) 37 | ids.push(file._id) 38 | } 39 | 40 | specialDoc = await cozy.client.data.create('io.cozy.testreferencer', { 41 | name: 'foo_' + random() 42 | }) 43 | 44 | await cozy.client.data.addReferencedFiles(specialDoc, ids) 45 | }) 46 | 47 | it('bind files to a doc', async function() { 48 | const anotherDoc = await createRandomFile(cozy, random) 49 | await cozy.client.data.addReferencedFiles(specialDoc, anotherDoc._id) 50 | const ids2 = await cozy.client.data.listReferencedFiles(specialDoc) 51 | ids2.should.eql(ids.concat(anotherDoc._id)) 52 | }) 53 | 54 | it('remove references', async function() { 55 | await cozy.client.data.removeReferencedFiles(specialDoc, ids[0]) 56 | const ids2 = await cozy.client.data.listReferencedFiles(specialDoc) 57 | ids2.should.not.containEql(ids[0]) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/integration/settings.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import should from 'should' 5 | import 'cross-fetch/polyfill' 6 | import { Client } from '../../src' 7 | 8 | const COZY_STACK_URL = (process.env && process.env.COZY_STACK_URL) || '' 9 | const COZY_STACK_VERSION = process.env && process.env.COZY_STACK_VERSION 10 | const COZY_STACK_TOKEN = process.env && process.env.COZY_STACK_TOKEN 11 | 12 | describe('settings api', async function() { 13 | const cozy = {} 14 | 15 | beforeEach(function() { 16 | if (COZY_STACK_VERSION === '2') { 17 | this.skip() 18 | } 19 | cozy.client = new Client({ 20 | cozyURL: COZY_STACK_URL, 21 | token: COZY_STACK_TOKEN 22 | }) 23 | }) 24 | 25 | it('gets the disk usage', async function() { 26 | const usage = await cozy.client.settings.diskUsage() 27 | usage.should.have.property('attributes') 28 | usage.attributes.used.should.be.type('string') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/mock-iframe-token.js: -------------------------------------------------------------------------------- 1 | export default function mockTokenRetrieve() { 2 | if (typeof global.window === 'undefined') global.window = {} 3 | let listener = null 4 | global.window.addEventListener = (n, l) => { 5 | listener = l 6 | } 7 | global.window.removeEventListener = () => null 8 | global.window.parent = {} 9 | global.window.location = { origin: 'fakecozy.local' } 10 | global.window.parent.postMessage = function(payload, origin) { 11 | if (payload.action === 'getToken' && origin === 'fakecozy.local') { 12 | listener({ 13 | data: { 14 | appName: process.env.NAME, 15 | token: process.env.TOKEN 16 | } 17 | }) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cozy-client-js-test", 3 | "version": "1.0.0", 4 | "description": "Necessary for v2 permissions", 5 | "cozy-permissions": { 6 | "All": { 7 | "description": "Can do everything" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/testapp-git-daemon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # set -e 3 | 4 | if [ "${#}" -ne 2 ] || ! [ -f "${2}" ]; then 5 | >&2 echo "Usage: ${0} [gitdir] [manifest]" 6 | exit 1 7 | fi 8 | 9 | testapp_dir="${1}" 10 | 11 | if [ -d "${testapp_dir}" ]; then 12 | >&2 echo "Directory ${testapp_dir} already exists" 13 | fi 14 | 15 | git init "${testapp_dir}" 16 | cat "${2}" > "${testapp_dir}/manifest.webapp" 17 | git --git-dir="${testapp_dir}/.git" --work-tree="${testapp_dir}" add . 18 | git --git-dir="${testapp_dir}/.git" --work-tree="${testapp_dir}" commit -m "manifest" 19 | git daemon --reuseaddr --base-path="${testapp_dir}" --export-all "${testapp_dir}/.git" 20 | -------------------------------------------------------------------------------- /test/testapp-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "A mini app to test cozy-stack-v2", 3 | "developer": { 4 | "name": "Foo Bar", 5 | "url": "foobar.io" 6 | }, 7 | "license": "MIT", 8 | "name": "testapp", 9 | "slug": "testapp", 10 | "version": "0.1.0", 11 | "permissions": { 12 | "files": { 13 | "description": "Required for tests of cozy-client-js", 14 | "type": "io.cozy.files" 15 | }, 16 | "jobs": { 17 | "description": "Required for tests of cozy-client-js", 18 | "type": "io.cozy.jobs" 19 | }, 20 | "queues": { 21 | "description": "Required for tests of cozy-client-js", 22 | "type": "io.cozy.queues" 23 | }, 24 | "testobject": { 25 | "description": "Required for tests of cozy-client-js", 26 | "type": "io.cozy.testobject" 27 | }, 28 | "testobject2": { 29 | "description": "Required for tests of cozy-client-js", 30 | "type": "io.cozy.testobject2" 31 | }, 32 | "idonotexist": { 33 | "description": "Required for tests of cozy-client-js", 34 | "type": "io.cozy.idonotexist" 35 | }, 36 | "testreferencer": { 37 | "description": "Required for tests of cozy-client-js", 38 | "type": "io.cozy.testreferencer" 39 | }, 40 | "settings": { 41 | "description": "Required for tests of cozy-client-js", 42 | "type": "io.cozy.settings", 43 | "verbs": ["GET", "POST", "PUT"] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/unit/data.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import { Client } from '../../src' 7 | import mock from '../mock-api' 8 | 9 | describe('data API', function() { 10 | const cozy = {} 11 | 12 | beforeEach(() => { 13 | cozy.client = new Client({ 14 | cozyURL: 'http://my.cozy.io///', 15 | token: 'apptoken' 16 | }) 17 | }) 18 | afterEach(() => mock.restore()) 19 | 20 | describe('Create document', function() { 21 | before(mock.mockAPI('CreateDoc')) 22 | 23 | it('Call the proper route', async function() { 24 | const testDoc = { test: 'value' } 25 | const created = await cozy.client.data.create( 26 | 'io.cozy.testobject', 27 | testDoc 28 | ) 29 | 30 | mock.calls('CreateDoc').should.have.length(1) 31 | mock 32 | .lastUrl('CreateDoc') 33 | .should.equal('http://my.cozy.io/data/io.cozy.testobject/') 34 | mock 35 | .lastOptions('CreateDoc') 36 | .should.have.property('body', '{"test":"value"}') 37 | 38 | created.should.have.property('_id') 39 | created.should.have.property('_rev') 40 | created.should.have.property('test', 'value') 41 | }) 42 | }) 43 | 44 | describe('Fetch document', function() { 45 | before(mock.mockAPI('GetDoc')) 46 | 47 | it('Call the proper route', async function() { 48 | let fetched = await cozy.client.data.find('io.cozy.testobject', '42') 49 | 50 | mock.calls('GetDoc').should.have.length(1) 51 | mock 52 | .lastUrl('GetDoc') 53 | .should.equal('http://my.cozy.io/data/io.cozy.testobject/42') 54 | mock.lastOptions('GetDoc').should.not.have.property('body') 55 | 56 | fetched.should.have.property('_id', '42') 57 | fetched.should.have.property('_rev', '1-5444878785445') 58 | fetched.should.have.property('test', 'value') 59 | }) 60 | }) 61 | 62 | describe('Fetch multiple documents at once', function() { 63 | before(mock.mockAPI('GetManyDocs')) 64 | 65 | it('Call the proper route', async function() { 66 | const resultsById = await cozy.client.data.findMany( 67 | 'io.cozy.testobject', 68 | ['42', '43'] 69 | ) 70 | 71 | mock.calls('GetManyDocs').should.have.length(1) 72 | mock 73 | .lastUrl('GetManyDocs') 74 | .should.equal( 75 | 'http://my.cozy.io/data/io.cozy.testobject/_all_docs?include_docs=true' 76 | ) 77 | mock 78 | .lastOptions('GetManyDocs') 79 | .should.have.property('body', '{"keys":["42","43"]}') 80 | 81 | resultsById.should.have.properties(['42', '43']) 82 | resultsById['42'].should.deepEqual({ 83 | doc: { 84 | _id: '42', 85 | _rev: '1-5444878785445', 86 | test: 'value' 87 | } 88 | }) 89 | resultsById['43'].should.deepEqual({ 90 | error: 'not_found' 91 | }) 92 | }) 93 | 94 | it('Resolves with an empty object when ids array is empty', async function() { 95 | const resultsById = await cozy.client.data.findMany( 96 | 'io.cozy.testobject', 97 | [] 98 | ) 99 | should(resultsById).deepEqual({}) 100 | }) 101 | 102 | it('Fails when ids is not an array', async function() { 103 | for (const ids of [undefined, null, 'foo', { foo: 'bar' }]) { 104 | await should( 105 | cozy.client.data.findMany('io.cozy.testobject', ids) 106 | ).be.rejectedWith(/ids/) 107 | } 108 | }) 109 | }) 110 | 111 | describe('Fetch all documents', function() { 112 | before(mock.mockAPI('GetAllDocs')) 113 | 114 | it('Call the proper route', async function() { 115 | const docs = await cozy.client.data.findAll('io.cozy.testobject') 116 | 117 | mock.calls('GetAllDocs').should.have.length(1) 118 | mock 119 | .lastUrl('GetAllDocs') 120 | .should.equal( 121 | 'http://my.cozy.io/data/io.cozy.testobject/_all_docs?include_docs=true' 122 | ) 123 | mock.lastOptions('GetAllDocs').should.have.property('body', '{}') 124 | 125 | should(docs.length).equal(2) 126 | docs[1].should.deepEqual({ 127 | _id: '43', 128 | _rev: '1-5444878785446', 129 | test: 'value2' 130 | }) 131 | }) 132 | }) 133 | 134 | describe('Fetch the changes feed', function() { 135 | before(mock.mockAPI('ChangesFeed')) 136 | 137 | it('Call the proper route', async function() { 138 | let fetched = await cozy.client.data.changesFeed('io.cozy.testobject', { 139 | since: 0 140 | }) 141 | 142 | mock.calls('ChangesFeed').should.have.length(1) 143 | mock 144 | .lastUrl('ChangesFeed') 145 | .should.equal( 146 | 'http://my.cozy.io/data/io.cozy.testobject/_changes?since=0' 147 | ) 148 | mock.lastOptions('ChangesFeed').should.not.have.property('body') 149 | 150 | fetched.should.have.property('last_seq', '42-abcdef') 151 | fetched.should.have.property('pending', 0) 152 | fetched.should.have.property('results') 153 | }) 154 | }) 155 | 156 | describe('Update document', function() { 157 | beforeEach(mock.mockAPI('UpdateDoc')) 158 | 159 | it('Call the proper route', async function() { 160 | const changes = { test: 'value2' } 161 | const updated = await cozy.client.data.update( 162 | 'io.cozy.testobject', 163 | { _id: '42', _rev: '1-5444878785445' }, 164 | changes 165 | ) 166 | 167 | mock.calls('UpdateDoc').should.have.length(1) 168 | mock 169 | .lastUrl('UpdateDoc') 170 | .should.equal('http://my.cozy.io/data/io.cozy.testobject/42') 171 | mock 172 | .lastOptions('UpdateDoc') 173 | .should.have.property( 174 | 'body', 175 | '{"_id":"42","_rev":"1-5444878785445","test":"value2"}' 176 | ) 177 | 178 | updated.should.have.property('_id', '42') 179 | updated.should.have.property('_rev', '2-5444878785445') 180 | updated.should.have.property('test', 'value2') 181 | }) 182 | 183 | it('Fails when doc is missing _id or _rev field', async function() { 184 | let err = null 185 | 186 | const changes = { test: 'value2' } 187 | 188 | try { 189 | await cozy.client.data.update( 190 | 'io.cozy.testobject', 191 | { _rev: '1-5444878785445' }, 192 | changes 193 | ) 194 | } catch (e) { 195 | err = e 196 | } finally { 197 | err.should.eql(new Error('Missing _id field in passed document')) 198 | } 199 | 200 | err = null 201 | 202 | try { 203 | await cozy.client.data.update( 204 | 'io.cozy.testobject', 205 | { _id: '42' }, 206 | changes 207 | ) 208 | } catch (e) { 209 | err = e 210 | } finally { 211 | err.should.eql(new Error('Missing _rev field in passed document')) 212 | } 213 | }) 214 | }) 215 | 216 | describe('Delete document', function() { 217 | before(mock.mockAPI('DeleteDoc')) 218 | 219 | it('Call the proper route', async function() { 220 | const deleted = await cozy.client.data.delete('io.cozy.testobject', { 221 | _id: '42', 222 | _rev: '1-5444878785445' 223 | }) 224 | 225 | mock.calls('DeleteDoc').should.have.length(1) 226 | mock 227 | .lastUrl('DeleteDoc') 228 | .should.equal( 229 | 'http://my.cozy.io/data/io.cozy.testobject/42?rev=1-5444878785445' 230 | ) 231 | mock.lastOptions('DeleteDoc').should.not.have.property('body') 232 | 233 | deleted.should.have.property('id', '42') 234 | deleted.should.have.property('rev', '1-5444878785445') 235 | }) 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /test/unit/fetch.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import should from 'should' 4 | import { FetchError } from '../../src/fetch' 5 | 6 | describe('FetchError', function() { 7 | const response = { url: 'whatever' } 8 | let error, reason 9 | 10 | beforeEach(() => { 11 | error = new FetchError(response, reason) 12 | }) 13 | 14 | context('when reason is a proper error with message', () => { 15 | before(() => { 16 | reason = new Error('some error') 17 | }) 18 | 19 | it('has the same message', async function() { 20 | should(error.toString()).equal(`FetchError: ${reason.message}`) 21 | }) 22 | }) 23 | 24 | context('when reason is a String (e.g. a text error response)', () => { 25 | before(() => { 26 | reason = 'some error string' 27 | }) 28 | 29 | it('has the String as an error message', () => { 30 | should(error.toString()).equal(`FetchError: some error string`) 31 | }) 32 | }) 33 | 34 | context('when reason is an object (e.g. a JSON error response)', () => { 35 | before(() => { 36 | reason = { error: 'not_found', status: 404 } 37 | }) 38 | 39 | it('has the string representation of the object as an error message', () => { 40 | should(error.toString()).equal( 41 | 'FetchError: {"error":"not_found","status":404}' 42 | ) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/unit/jsonapi.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import jsonapiUnpack from '../../src/jsonapi' 7 | 8 | describe('unpacking', function() { 9 | it('simple data', function() { 10 | let result = jsonapiUnpack({ 11 | links: { 12 | self: '/io.cozy.testobject/42' 13 | }, 14 | data: { 15 | attributes: { 16 | test: 'value' 17 | }, 18 | type: 'io.cozy.testobject', 19 | id: '42', 20 | meta: { 21 | rev: '1-24' 22 | } 23 | } 24 | }) 25 | 26 | result.should.have.property('_id', '42') 27 | result.should.have.property('_rev', '1-24') 28 | result.should.have.property('attributes') 29 | result.attributes.should.have.property('test', 'value') 30 | }) 31 | 32 | it('array data', function() { 33 | let result = jsonapiUnpack({ 34 | data: [ 35 | { 36 | attributes: { test: 'value' }, 37 | type: 'io.cozy.testobject', 38 | id: '42', 39 | links: { self: '/io.cozy.testobject/42' }, 40 | meta: { rev: '1-24' } 41 | }, 42 | { 43 | attributes: { test: 'value2' }, 44 | type: 'io.cozy.testobject', 45 | id: '43', 46 | links: { self: '/io.cozy.testobject/42' }, 47 | meta: { rev: '1-34' } 48 | } 49 | ] 50 | }) 51 | 52 | result.should.be.an.Array() 53 | result.should.have.length(2) 54 | result[0].should.have.property('_rev', '1-24') 55 | result[0].should.have.property('_id', '42') 56 | result[0].should.have.property('attributes') 57 | result[0].attributes.should.have.property('test', 'value') 58 | 59 | result[1].should.have.property('_id', '43') 60 | result[1].should.have.property('_rev', '1-34') 61 | result[1].should.have.property('attributes') 62 | result[1].attributes.should.have.property('test', 'value2') 63 | }) 64 | 65 | it('included', function() { 66 | let result = jsonapiUnpack(COMPLEX) 67 | 68 | result.should.have.property('_id', 'top1') 69 | result.should.have.property('_rev', '1-a59751f9c66867758a7f2b4ebdd9d05f') 70 | result.should.have.property('attributes') 71 | result.attributes.should.have.property('test', 'top1') 72 | 73 | let contents = result.relations('contents') 74 | 75 | should(contents).not.be.Undefined() 76 | contents.should.be.an.Array() 77 | contents.should.have.length(2) 78 | 79 | contents[0].should.have.property('_id', 'child1') 80 | contents[0].should.have.property( 81 | '_rev', 82 | '1-1ae365a207bb5eb5c2d3cb8417b5885b' 83 | ) 84 | contents[0].should.have.property('attributes') 85 | contents[0].attributes.should.have.property('test', 'child1') 86 | 87 | let parent = contents[0].relations('parent') 88 | should(parent === result).be.true 89 | }) 90 | }) 91 | 92 | const COMPLEX = { 93 | data: { 94 | id: 'top1', 95 | type: 'io.cozy.files', 96 | attributes: { test: 'top1' }, 97 | links: { self: '/files/top1' }, 98 | meta: { rev: '1-a59751f9c66867758a7f2b4ebdd9d05f' }, 99 | relationships: { 100 | contents: { 101 | data: [ 102 | { id: 'child1', type: 'io.cozy.files' }, 103 | { id: 'child2', type: 'io.cozy.files' } 104 | ] 105 | }, 106 | parent: { data: null } 107 | } 108 | }, 109 | included: [ 110 | { 111 | id: 'child1', 112 | type: 'io.cozy.files', 113 | attributes: { test: 'child1' }, 114 | links: { self: '/files/child1' }, 115 | meta: { rev: '1-1ae365a207bb5eb5c2d3cb8417b5885b' }, 116 | relationships: { 117 | contents: { data: [] }, 118 | parent: { 119 | data: { id: 'top1', type: 'io.cozy.files' }, 120 | links: { related: '/files/top1' } 121 | } 122 | } 123 | }, 124 | { 125 | id: 'child2', 126 | type: 'io.cozy.files', 127 | attributes: { test: 'child2' }, 128 | links: { self: '/files/child2' }, 129 | meta: { rev: '1-286f5cbda1f40cdede2364bb9c7c1564' }, 130 | relationships: { 131 | contents: { data: [] }, 132 | parent: { 133 | data: { id: 'top1', type: 'io.cozy.files' }, 134 | links: { related: '/files/top1' } 135 | } 136 | } 137 | } 138 | ] 139 | } 140 | -------------------------------------------------------------------------------- /test/unit/mango.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import { Client } from '../../src' 7 | import mock from '../mock-api' 8 | 9 | describe('mango API', function() { 10 | let indexRef 11 | const cozy = {} 12 | 13 | beforeEach(() => { 14 | cozy.client = new Client({ 15 | cozyURL: 'http://my.cozy.io///', 16 | token: 'apptoken' 17 | }) 18 | }) 19 | 20 | afterEach(() => mock.restore()) 21 | 22 | describe('Create index', function() { 23 | before(mock.mockAPI('CreateIndex')) 24 | 25 | it('Call the proper route', async function() { 26 | const testIndex = ['field1', 'field2'] 27 | indexRef = await cozy.client.data.defineIndex( 28 | 'io.cozy.testobject', 29 | testIndex 30 | ) 31 | 32 | mock.calls('CreateIndex').should.have.length(1) 33 | mock 34 | .lastUrl('CreateIndex') 35 | .should.equal('http://my.cozy.io/data/io.cozy.testobject/_index') 36 | mock 37 | .lastOptions('CreateIndex') 38 | .should.have.property( 39 | 'body', 40 | '{"index":{"fields":["field1","field2"]}}' 41 | ) 42 | 43 | indexRef.should.have.property('type', 'mango') 44 | indexRef.should.have.property('doctype', 'io.cozy.testobject') 45 | indexRef.should.have.property('name', '_design/generatedindexname') 46 | indexRef.should.have.property('fields') 47 | indexRef.fields.should.deepEqual(['field1', 'field2']) 48 | }) 49 | }) 50 | 51 | describe('Find documents', function() { 52 | before(mock.mockAPI('FindDocuments')) 53 | 54 | it('Call the proper route', async function() { 55 | let fetched = await cozy.client.data.query(indexRef, { 56 | selector: { field1: 'value' } 57 | }) 58 | 59 | mock.calls('FindDocuments').should.have.length(1) 60 | mock 61 | .lastUrl('FindDocuments') 62 | .should.equal('http://my.cozy.io/data/io.cozy.testobject/_find') 63 | mock 64 | .lastOptions('FindDocuments') 65 | .should.have.property( 66 | 'body', 67 | '{"use_index":"_design/generatedindexname","selector":{"field1":"value"}}' 68 | ) 69 | 70 | fetched.should.be.an.Array() 71 | fetched.should.have.length(2) 72 | fetched[0].should.have.property('_id', '42') 73 | fetched[0].should.have.property('test', 'value') 74 | fetched[1].should.have.property('_id', '43') 75 | fetched[1].should.have.property('test', 'value') 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /test/unit/mango_utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import { parseSelector, makeMapReduceQuery } from '../../src/mango' 7 | 8 | describe('selector parsing', function() { 9 | it('simple selector', function() { 10 | let parsed = parseSelector({ test: 'value' }) 11 | parsed.should.deepEqual([[['test'], '$eq', 'value']]) 12 | }) 13 | 14 | it('two fields selector', function() { 15 | let parsed = parseSelector({ test: 'value', test2: 'value2' }) 16 | parsed.should.deepEqual([ 17 | [['test'], '$eq', 'value'], 18 | [['test2'], '$eq', 'value2'] 19 | ]) 20 | }) 21 | 22 | it('two fields selector', function() { 23 | let parsed = parseSelector({ test: { testdeep: 'value' }, test2: 'value2' }) 24 | parsed.should.deepEqual([ 25 | [['test', 'testdeep'], '$eq', 'value'], 26 | [['test2'], '$eq', 'value2'] 27 | ]) 28 | }) 29 | 30 | it('operator selector', function() { 31 | let parsed = parseSelector({ test: { $gt: 3 } }) 32 | parsed.should.deepEqual([[['test'], '$gt', 3]]) 33 | }) 34 | 35 | it('double operator selector', function() { 36 | let parsed = parseSelector({ test: { $gt: 2, $lt: 3 } }) 37 | parsed.should.deepEqual([[['test'], '$gt', 2], [['test'], '$lt', 3]]) 38 | }) 39 | }) 40 | 41 | describe('selector to MR query', function() { 42 | it('simple selector', function() { 43 | let indexDef = { 44 | type: 'mapreduce', 45 | fields: ['dirID', 'date'], 46 | name: 'testindex' 47 | } 48 | let mrq = makeMapReduceQuery(indexDef, { selector: { dirID: '42' } }) 49 | 50 | mrq.should.deepEqual({ 51 | startkey: ['42'], 52 | reduce: false, 53 | endkey: ['42', { '\uFFFF': '\uFFFF' }] 54 | }) 55 | }) 56 | 57 | it('double selector', function() { 58 | let indexDef = { 59 | type: 'mapreduce', 60 | fields: ['dirID', 'date'], 61 | name: 'testindex' 62 | } 63 | let mrq = makeMapReduceQuery(indexDef, { 64 | selector: { 65 | dirID: '42', 66 | date: '2101' 67 | } 68 | }) 69 | 70 | mrq.should.deepEqual({ 71 | startkey: ['42', '2101'], 72 | reduce: false, 73 | endkey: ['42', '2101'] 74 | }) 75 | }) 76 | 77 | it('operator selector', function() { 78 | let indexDef = { 79 | type: 'mapreduce', 80 | fields: ['dirID', 'date'], 81 | name: 'testindex' 82 | } 83 | let mrq = makeMapReduceQuery(indexDef, { 84 | selector: { 85 | dirID: '42', 86 | date: { $gte: '2101' } 87 | } 88 | }) 89 | 90 | mrq.should.deepEqual({ 91 | startkey: ['42', '2101'], 92 | reduce: false, 93 | endkey: ['42', { '\uFFFF': '\uFFFF' }] 94 | }) 95 | }) 96 | 97 | it('double operator selector', function() { 98 | let indexDef = { 99 | type: 'mapreduce', 100 | fields: ['dirID', 'date'], 101 | name: 'testindex' 102 | } 103 | let mrq = makeMapReduceQuery(indexDef, { 104 | selector: { 105 | dirID: '42', 106 | date: { $gte: '2101', $lt: '2201' } 107 | } 108 | }) 109 | 110 | mrq.should.deepEqual({ 111 | startkey: ['42', '2101'], 112 | reduce: false, 113 | endkey: ['42', '2201'], 114 | inclusive_end: false 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/unit/offline.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import { Client } from '../../src' 7 | import PouchDB from 'pouchdb-browser' 8 | import pouchdbFind from 'pouchdb-find' 9 | PouchDB.plugin(require('pouchdb-adapter-memory')) 10 | 11 | // PouchDB should not be a mandatory dependency as it is only used in mobile 12 | // environment, so we declare it in global scope here. 13 | global.PouchDB = PouchDB 14 | global.pouchdbFind = pouchdbFind 15 | 16 | describe('offline', () => { 17 | const fileDoctype = 'io.cozy.files' 18 | const otherDoctype = 'io.cozy.others' 19 | const cozyUrl = 'http://cozy.tools:8080/' 20 | let offlineParameter = { 21 | doctypes: [fileDoctype, otherDoctype], 22 | options: { adapter: 'memory' } 23 | } 24 | const cozy = {} 25 | 26 | describe('Initialise offline', () => { 27 | it('is disable by default', () => { 28 | cozy.client = new Client({ 29 | cozyURL: cozyUrl, 30 | token: 'apptoken' 31 | }) 32 | const isNotDefined = cozy.client._offline === null 33 | isNotDefined.should.be.true 34 | }) 35 | 36 | it('create couchdb database for each doctype', () => { 37 | cozy.client = new Client({ 38 | cozyURL: cozyUrl, 39 | offline: offlineParameter, 40 | token: 'apptoken' 41 | }) 42 | cozy.client._offline.should.be.an.Array() 43 | cozy.client._offline.should.have.property(fileDoctype) 44 | cozy.client._offline.should.have.property(otherDoctype) 45 | cozy.client._offline[fileDoctype].database.should.be.an.instanceof( 46 | PouchDB 47 | ) 48 | cozy.client._offline[otherDoctype].database.should.be.an.instanceof( 49 | PouchDB 50 | ) 51 | }) 52 | 53 | it('is possible to enable after cozy init', () => { 54 | cozy.client = new Client({ 55 | cozyURL: cozyUrl, 56 | token: 'apptoken' 57 | }) 58 | cozy.client.offline.createDatabase(fileDoctype, { adapter: 'memory' }) 59 | cozy.client._offline.should.be.an.Array() 60 | cozy.client._offline.should.have.property(fileDoctype) 61 | }) 62 | }) 63 | 64 | describe('doctype database', () => { 65 | beforeEach(() => { 66 | cozy.client = new Client({ 67 | cozyURL: cozyUrl, 68 | token: 'apptoken' 69 | }) 70 | }) 71 | 72 | it('should create database', async () => { 73 | let db = await cozy.client.offline.createDatabase(fileDoctype, { 74 | adapter: 'memory' 75 | }) 76 | db.name.should.be.equal(fileDoctype) 77 | db.should.be.an.instanceof(PouchDB) 78 | }) 79 | 80 | it('should verify database exist', () => { 81 | cozy.client.offline.hasDatabase(fileDoctype).should.be.false 82 | cozy.client.offline.createDatabase(fileDoctype, { adapter: 'memory' }) 83 | cozy.client.offline.hasDatabase(fileDoctype).should.be.true 84 | }) 85 | 86 | it('should get database', async () => { 87 | should.not.exist(cozy.client.offline.getDatabase(fileDoctype)) 88 | await cozy.client.offline.createDatabase(fileDoctype, { 89 | adapter: 'memory' 90 | }) 91 | let db = cozy.client.offline.getDatabase(fileDoctype) 92 | db.name.should.be.equal(fileDoctype) 93 | db.should.be.an.instanceof(PouchDB) 94 | }) 95 | 96 | it('should destroy database', async () => { 97 | await cozy.client.offline.createDatabase(fileDoctype, { 98 | adapter: 'memory' 99 | }) 100 | cozy.client.offline.hasDatabase(fileDoctype).should.be.true 101 | await cozy.client.offline.destroyDatabase(fileDoctype) 102 | cozy.client.offline.hasDatabase(fileDoctype).should.be.false 103 | }) 104 | 105 | it('should return doctypes', () => { 106 | // no offline 107 | cozy.client.offline.getDoctypes().should.be.eql([]) 108 | // with one or more doctype offline 109 | cozy.client.offline.createDatabase(fileDoctype, { adapter: 'memory' }) 110 | cozy.client.offline.getDoctypes().should.be.eql([fileDoctype]) 111 | cozy.client.offline.createDatabase(otherDoctype, { adapter: 'memory' }) 112 | cozy.client.offline 113 | .getDoctypes() 114 | .should.be.eql([fileDoctype, otherDoctype]) 115 | }) 116 | 117 | it('should create mongo index when create database', async () => { 118 | let db = await cozy.client.offline.createDatabase(fileDoctype, { 119 | adapter: 'memory' 120 | }) 121 | await db.getIndexes().then(result => { 122 | result.indexes.length.should.be.greaterThan(0) 123 | }) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /test/unit/settings.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | import 'cross-fetch/polyfill' 5 | import should from 'should' 6 | import { Client } from '../../src' 7 | import mock from '../mock-api' 8 | 9 | describe('settings', function() { 10 | const cozy = {} 11 | 12 | beforeEach(() => { 13 | cozy.client = new Client({ 14 | cozyURL: 'http://my.cozy.io///', 15 | token: 'apptoken' 16 | }) 17 | }) 18 | afterEach(() => mock.restore()) 19 | 20 | describe('Disk usage', function() { 21 | before(mock.mockAPI('DiskUsage')) 22 | 23 | it('should work', async function() { 24 | const usage = await cozy.client.settings.diskUsage() 25 | usage._id.should.equal('io.cozy.settings.disk-usage') 26 | usage._type.should.equal('io.cozy.settings') 27 | usage.attributes.used.should.equal('123') 28 | }) 29 | }) 30 | 31 | describe('Changing the passphrase', function() { 32 | before(mock.mockAPI('Passphrase')) 33 | 34 | it('should work', async function() { 35 | await cozy.client.settings.changePassphrase('current', 'new') 36 | }) 37 | }) 38 | 39 | describe('Get instance', function() { 40 | before(mock.mockAPI('GetInstance')) 41 | 42 | it('should work', async function() { 43 | const instance = await cozy.client.settings.getInstance() 44 | instance._id.should.equal('io.cozy.settings.instance') 45 | instance._type.should.equal('io.cozy.settings') 46 | instance.attributes.locale.should.equal('fr') 47 | instance.attributes.email.should.equal('alice@example.com') 48 | instance.attributes.public_name.should.equal('Alice Martin') 49 | }) 50 | }) 51 | 52 | describe('Update instance', function() { 53 | before(mock.mockAPI('GetInstance')) 54 | before(mock.mockAPI('UpdateInstance')) 55 | 56 | it('should work', async function() { 57 | const newLocale = 'en' 58 | let oldInstance = await cozy.client.settings.getInstance() 59 | oldInstance.attributes.locale = newLocale 60 | let instance = await cozy.client.settings.updateInstance(oldInstance) 61 | instance._id.should.equal('io.cozy.settings.instance') 62 | instance._type.should.equal('io.cozy.settings') 63 | instance.attributes.locale.should.equal(newLocale) 64 | instance._rev.should.equal('2') 65 | instance._rev.should.not.equal(oldInstance._rev) 66 | }) 67 | }) 68 | 69 | describe('Get clients', function() { 70 | before(mock.mockAPI('GetClients')) 71 | 72 | it('should work', async function() { 73 | const clients = await cozy.client.settings.getClients() 74 | clients.should.be.instanceOf(Array) 75 | clients.should.have.a.lengthOf(1) 76 | 77 | let client = clients[0] 78 | client._type.should.equal('io.cozy.oauth.clients') 79 | client.attributes.should.have.properties({ 80 | redirect_uris: ['http://localhost:4000/oauth/callback'], 81 | client_name: 'Cozy-Desktop on my-new-laptop', 82 | client_kind: 'desktop', 83 | client_uri: 'https://docs.cozy.io/en/mobile/desktop.html', 84 | logo_uri: 'https://docs.cozy.io/assets/images/cozy-logo-docs.svg', 85 | policy_uri: 'https://cozy.io/policy', 86 | software_id: '/github.com/cozy-labs/cozy-desktop', 87 | software_version: '0.16.0' 88 | }) 89 | }) 90 | }) 91 | 92 | describe('Delete a client by id', function() { 93 | before(mock.mockAPI('DeleteClient')) 94 | 95 | it('should work', async function() { 96 | await cozy.client.settings.deleteClientById('123') 97 | }) 98 | }) 99 | 100 | describe('call the synchronisation route', function() { 101 | before(mock.mockAPI('SyncedClient')) 102 | 103 | it('should work', async function() { 104 | await cozy.client.settings.updateLastSync() 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/webapp/manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Test cozy-client-js", 3 | "developer": { 4 | "name": "Cozy", 5 | "url": "cozy.io" 6 | }, 7 | "license": "MIT", 8 | "name": "Test cozy-client-js", 9 | "permissions": {}, 10 | "slug": "cozy-client-js-test", 11 | "version": "0.0.1" 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // webpack.config.js 2 | 3 | var nodeExternals = require('webpack-node-externals') 4 | var path = require('path') 5 | var webpack = require('webpack') 6 | 7 | var NODE_TARGET = process.env.NODE_TARGET || 'web' 8 | var production = process.env.NODE_ENV === 'production' 9 | 10 | var output = { 11 | path: path.join(__dirname, '/dist') 12 | } 13 | 14 | if (NODE_TARGET === 'web') { 15 | Object.assign(output, { 16 | filename: production ? 'cozy-client.min.js' : 'cozy-client.js', 17 | library: ['cozy', 'client'], 18 | libraryTarget: 'umd', 19 | umdNamedDefine: true 20 | }) 21 | } else { 22 | Object.assign(output, { 23 | filename: 'cozy-client.node.js', 24 | libraryTarget: 'commonjs' 25 | }) 26 | } 27 | 28 | var config = { 29 | entry: path.join(__dirname, 'src', 'index.js'), 30 | devtool: 'source-map', 31 | target: NODE_TARGET, 32 | output: output, 33 | resolve: { 34 | modules: ['node_modules', path.resolve('./src')], 35 | extensions: ['.js'] 36 | }, 37 | module: { 38 | loaders: [ 39 | { 40 | test: /\.js$/, 41 | loader: 'babel-loader', 42 | exclude: /node_modules/ 43 | } 44 | ] 45 | }, 46 | node: { 47 | crypto: false 48 | } 49 | } 50 | 51 | if (NODE_TARGET === 'node') { 52 | config.externals = [nodeExternals()] 53 | config.plugins = [ 54 | new webpack.ProvidePlugin({ 55 | btoa: 'btoa' 56 | }), 57 | new webpack.EnvironmentPlugin(Object.keys(process.env)) 58 | ] 59 | } else if (production) { 60 | config.plugins = [ 61 | new webpack.optimize.OccurrenceOrderPlugin(), 62 | new webpack.optimize.UglifyJsPlugin({ 63 | mangle: true, 64 | compress: { 65 | warnings: false 66 | } 67 | }) 68 | ] 69 | } 70 | 71 | module.exports = config 72 | --------------------------------------------------------------------------------