├── test ├── locales │ ├── es.json │ ├── master.json │ └── en.json ├── test-image.jpg ├── lib │ └── modules │ │ ├── products │ │ ├── views │ │ │ └── api │ │ │ │ └── fragment.html │ │ └── index.js │ │ └── apostrophe-pages │ │ └── views │ │ └── api │ │ └── fragment.html ├── package.json ├── workflow.js └── test.js ├── jack-o-lantern-head.jpg ├── .eslintrc ├── .travis.yml ├── .circleci └── config.yml ├── .gitignore ├── LICENSE.md ├── package.json ├── CHANGELOG.md ├── index.js ├── lib └── modules │ ├── apostrophe-pieces-headless │ └── index.js │ └── apostrophe-pages-headless │ └── index.js └── README.md /test/locales/es.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/locales/master.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/test-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-headless/HEAD/test/test-image.jpg -------------------------------------------------------------------------------- /jack-o-lantern-head.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-headless/HEAD/jack-o-lantern-head.jpg -------------------------------------------------------------------------------- /test/lib/modules/products/views/api/fragment.html: -------------------------------------------------------------------------------- 1 |

{{ data.piece.title }}

2 | 3 | {{ apos.area(data.piece, 'body') }} 4 | 5 | -------------------------------------------------------------------------------- /test/lib/modules/apostrophe-pages/views/api/fragment.html: -------------------------------------------------------------------------------- 1 |

{{ data.page.title }}

2 | 3 | {{ apos.area(data.page, 'body') }} 4 | 5 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "apostrophe-headless": "^2.0.0", 4 | "apostrophe": "^2.0.0", 5 | "apostrophe-workflow": "^2.0.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "apostrophe", 3 | "ignorePatterns": [ 4 | "node_modules/", 5 | "**/node_modules/", 6 | "test/public/modules/" 7 | ], 8 | "rules": { 9 | "node/no-callback-literal": "off", 10 | "quote-props": "off", 11 | "dot-notation": "off", 12 | "node/no-path-concat": "off" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/lib/modules/products/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | construct: function(self, options) { 3 | self.apos.app.post('/excepted-post-route', function(req, res) { 4 | return res.send('ok'); 5 | }); 6 | self.apos.app.post('/non-excepted-post-route', function(req, res) { 7 | // Should not get here due to CSRF middleware 8 | return res.send('ok'); 9 | }); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /test/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Server error, please try again.": "Server error, please try again.", 3 | "An error has occurred": "An error has occurred", 4 | "An error has occurred. We're working on it. We apologize for the inconvenience.": "An error has occurred. We're working on it. We apologize for the inconvenience.", 5 | "Use the Add Content button to get started.": "Use the Add Content button to get started.", 6 | "": "" 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "lts/*" 5 | sudo: false 6 | services: 7 | - mongodb 8 | 9 | # We need to download MongoDB 2.6.10 10 | env: 11 | global: 12 | - MONGODB_VERSION=2.6.10 13 | before_install: 14 | - wget http://fastdl.mongodb.org/linux/mongodb-linux-x86_64-$MONGODB_VERSION.tgz 15 | - tar xfz mongodb-linux-x86_64-$MONGODB_VERSION.tgz 16 | - export PATH=`pwd`/mongodb-linux-x86_64-$MONGODB_VERSION/bin:$PATH 17 | - mkdir -p data/db 18 | - mongod --dbpath=data/db > /dev/null 2>&1 & 19 | - sleep 3 20 | 21 | # whitelist 22 | #branches: 23 | # only: 24 | # - master 25 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10-browsers 6 | - image: mongo:3.6.11 7 | steps: 8 | - checkout 9 | - run: 10 | name: update-npm 11 | command: 'sudo npm install -g npm@6' 12 | - restore_cache: 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | - run: 15 | name: install-npm-wee 16 | command: npm install 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "package.json" }} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: test 23 | command: npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test/data/temp 2 | package-lock.json 3 | test/public/modules/* 4 | test/public/css/master-* 5 | npm-debug.log 6 | *.DS_Store 7 | *.npmignore 8 | node_modules 9 | test/node_modules 10 | 11 | # We do not commit CSS, only LESS, with the exception of a few vendor CSS files we don't have LESS for 12 | */public/css/*.css 13 | lib/modules/*/public/css/*.css 14 | */public/css/*.css 15 | lib/modules/*/public/css/*.css 16 | lib/modules/apostrophe-assets/public/css/vendor/cropper.css 17 | lib/modules/apostrophe-assets/public/css/vendor/pikaday.css 18 | lib/modules/apostrophe-ui/public/css/vendor/font-awesome/font-awesome.css 19 | # Never commit a CSS map file, anywhere 20 | *.css.map 21 | 22 | # Dont commit test generated css 23 | test/public/css/*.css 24 | test/public/css/master-*.less 25 | 26 | # Dont commit test uploads 27 | test/public/uploads 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 P'unk Avenue LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apostrophe-headless", 3 | "version": "2.12.1", 4 | "description": "Use Apostrophe as a headless CMS with REST APIs for your apps", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npx eslint . && mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/apostrophecms/apostrophe-headless.git" 12 | }, 13 | "keywords": [ 14 | "apostrophecms", 15 | "apostrophe", 16 | "rest", 17 | "api", 18 | "apostrophe-cms", 19 | "headless", 20 | "headless-cms" 21 | ], 22 | "author": "Apostrophe Technologies, Inc.", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/apostrophecms/apostrophe-headless/issues" 26 | }, 27 | "homepage": "https://github.com/apostrophecms/apostrophe-headless#readme", 28 | "dependencies": { 29 | "async": "^2.5.0", 30 | "bluebird": "^3.5.4", 31 | "cors": "^2.8.4", 32 | "cuid": "^1.3.8", 33 | "express-bearer-token": "^2.1.0", 34 | "lodash": "^4.17.15" 35 | }, 36 | "devDependencies": { 37 | "apostrophe": "^2.105.0", 38 | "apostrophe-workflow": "^2.19.0", 39 | "eslint": "^7.11.0", 40 | "eslint-config-apostrophe": "^3.4.0", 41 | "eslint-plugin-node": "^11.1.0", 42 | "eslint-plugin-promise": "^4.2.1", 43 | "eslint-plugin-standard": "^4.0.2", 44 | "fs-extra": "^4.0.2", 45 | "mocha": "^7.0.0", 46 | "request": "^2.83.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/workflow.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var request = require('request'); 3 | var _ = require('lodash'); 4 | var Promise = require('bluebird'); 5 | 6 | // So far this is a very basic test of public read access to the live locales, 7 | // which is what has been implemented so far for workflow 8 | 9 | describe('test apostrophe-headless with workflow', function() { 10 | 11 | var apos; 12 | 13 | this.timeout(20000); 14 | 15 | after(function(done) { 16 | require('apostrophe/test-lib/util').destroy(apos, done); 17 | }); 18 | 19 | it('initializes', function(done) { 20 | apos = require('apostrophe')({ 21 | testModule: true, 22 | shortName: 'apostrophe-headless-test', 23 | modules: { 24 | 'apostrophe-express': { 25 | secret: 'xxx', 26 | port: 7900 27 | }, 28 | 'apostrophe-headless': {}, 29 | 'apostrophe-pages': { 30 | restApi: true 31 | }, 32 | products: { 33 | extend: 'apostrophe-pieces', 34 | restApi: true, 35 | name: 'product' 36 | }, 37 | 'apostrophe-workflow': { 38 | defaultLocale: 'en', 39 | locales: [ 40 | { 41 | name: 'master', 42 | private: true, 43 | children: [ 44 | { 45 | name: 'en', 46 | label: 'English' 47 | }, 48 | { 49 | name: 'es', 50 | label: 'Spanish' 51 | } 52 | ] 53 | } 54 | ] 55 | } 56 | }, 57 | afterInit: function(callback) { 58 | // Should NOT have an alias! 59 | assert(!apos.restApi); 60 | assert(apos.modules.products); 61 | assert(apos.modules.products.addRestApiRoutes); 62 | return callback(null); 63 | }, 64 | afterListen: function(err) { 65 | assert(!err); 66 | done(); 67 | } 68 | }); 69 | }); 70 | 71 | it('can insert test documents (live) via raw mongo to enable read tests', function() { 72 | var docs = []; 73 | var workflow = apos.modules['apostrophe-workflow']; 74 | _.each(_.keys(workflow.locales), function(locale) { 75 | docs.push( 76 | { 77 | type: 'product', 78 | title: 'product test', 79 | slug: 'product-test', 80 | _id: 'product' + locale, 81 | workflowGuid: 'producttest', 82 | workflowLocale: locale, 83 | published: true 84 | } 85 | ); 86 | }); 87 | return apos.docs.db.insert(docs); 88 | }); 89 | 90 | it('can access the appropriate product for each locale via GET requests with _workflowLocale query parameter', function() { 91 | var workflow = apos.modules['apostrophe-workflow']; 92 | var locales = _.keys(workflow.locales); 93 | locales = _.filter(locales, function(locale) { 94 | return (!workflow.locales[locale].private) && (workflow.liveify(locale) === locale); 95 | }); 96 | return Promise.mapSeries(locales, function(locale) { 97 | return http('/api/v1/products', 'GET', { _workflowLocale: locale }, {}, undefined).then(function(response) { 98 | assert(response); 99 | assert(response.results); 100 | assert(response.results.length === 1); 101 | assert(response.results[0].workflowLocale === locale); 102 | assert(response.results[0]._id === ('product' + locale)); 103 | }); 104 | }); 105 | }); 106 | 107 | it('can access the appropriate homepage for each locale via GET requests with _workflowLocale query parameter', function() { 108 | var workflow = apos.modules['apostrophe-workflow']; 109 | var locales = _.keys(workflow.locales); 110 | locales = _.filter(locales, function(locale) { 111 | return (!workflow.locales[locale].private) && (workflow.liveify(locale) === locale); 112 | }); 113 | return Promise.mapSeries(locales, function(locale) { 114 | return http('/api/v1/apostrophe-pages', 'GET', { _workflowLocale: locale }, {}, undefined).then(function(response) { 115 | assert(response); 116 | assert(response.workflowLocale === locale); 117 | assert(response.path === '/'); 118 | }); 119 | }); 120 | }); 121 | 122 | }); 123 | 124 | function http(url, method, query, form, bearer, extra) { 125 | return Promise.promisify(body)(); 126 | function body(callback) { 127 | if (arguments.length === 6) { 128 | callback = extra; 129 | extra = null; 130 | } 131 | var args = { 132 | url: 'http://localhost:7900' + url, 133 | qs: query || undefined, 134 | form: ((method === 'POST') || (method === 'PUT') || (method === 'PATCH')) ? form : undefined, 135 | method: method, 136 | json: true, 137 | auth: bearer ? { bearer: bearer } : undefined 138 | }; 139 | if (extra) { 140 | _.assign(args, extra); 141 | } 142 | return request(args, function(err, response, body) { 143 | if (err) { 144 | return callback(err); 145 | } 146 | if (response.statusCode >= 400) { 147 | return callback({ 148 | status: response.statusCode, 149 | body: body 150 | }); 151 | } 152 | return callback(null, body); 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.12.1 2021-09-13 2 | * Tests pass with latest `moog-require`. No code changes. 3 | 4 | ## 2.12.0 2021-05-20 5 | * Adds ability to pass cors config to the module. 6 | 7 | ## 2.11.0 2020-12-02 8 | 9 | * Added an `apostrophe-headless:beforeLogin` promise event. Thanks to Jose García of swiss4ward for the contribution. 10 | * Eslint settings updated. 11 | 12 | ## 2.10.3 2020-11-04 13 | 14 | * Fixes a typo in the README. Thanks to [Adrien Delannoy](https://github.com/Adrien-D) for the contribution. Updates `mocha` for a vulnerability warning, as well as ESLint. 15 | 16 | ## 2.10.2 17 | 18 | * Fixes minor ESLint errors. 19 | 20 | ## 2.10.1 21 | 22 | * Fixed bug in PATCH feature when using dot paths. 23 | 24 | ## 2.10.0 25 | 26 | * Allows requests for pages to include `children=false` and `ancestors=false` query parameters that disable those respective properties on the response. Thanks to [Paul Grieselhuber](https://github.com/paulisloud) for the suggestion and original work on this feature. 27 | 28 | ## 2.9.5 29 | 30 | * Updates the ESLint configuration to `eslint-config-apostrophe@3.10` and fixes linter errors. 31 | 32 | ## 2.9.4 33 | 34 | * Make sure the slug follows the title when inserting a piece even if the slug is not explicitly POSTed. 35 | 36 | ## 2.9.3 37 | 38 | * Fix issue introduced in apostrophe 2.102.0 where newly inserted pieces are unpublished, etc. You must update both this module and `apostrophe` to get the benefit of this fix. 39 | 40 | ## 2.9.2 41 | 42 | * Fetches of a single piece (`GET /pieces/ID`) now respect excluded fields and `editPermissionRequired` in the same way that `GET /pieces` already did. 43 | 44 | ## 2.9.1 45 | 46 | * The `csrf: exceptions` option to `apostrophe-express` now works properly in the presence of this module. 47 | 48 | ## 2.9.0 49 | 50 | * Basic support for combining apostrophe-workflow with this module. GET requests now succeed as expected when the `_workflowLocale` query parameter is used to specify the desired locale. Otherwise, as before, you receive content for the default locale only. Note that you can only obtain live content, not draft content. POST, PUT and PATCH requests currently are not fully supported with workflow. Note that there is no issue if the doc type in question is excluded from workflow via `excludeTypes`. It is our intention to provide more complete support for headless workflow over time. 51 | 52 | ## 2.8.0 53 | 54 | * Various tickets have been opened due to confusion around what happens if you use `includeFields` or `excludeFields` and, as a result, you do not include the following fields: `type`, `_id`, `tags`, `slug` and `docPermissions`. This can lead to the unexpected failure of joins, the unexpected absence of the `_url` property (or a bad value for that property), and the unexpected absence of the `_edit: true` property. This release fixes this issue by always including these fields in the MongoDB query, but excluding them after the fact in the returned array of results if they are present in `excludeFields`. 55 | * The current `page` property is always included in the response when fetching pieces. 56 | * eslint added to the tests, passes the apostrophe eslint config. 57 | 58 | ## 2.7.1 59 | 60 | * The `PATCH` method works properly with `joinByOne` and `joinByArray`. You should send the appropriate `idField` or `idsField`. If these are not explicitly configured, the names map as follows: `_joinName` maps to `joinNameIdField` or `joinNameIdsField` (note there is no `_`), depending on whether it is a `joinByOne` or `joinByArray` field. Thanks to Giuseppe Monteleone for flagging the issue. 61 | * In certain cases, a crash occurred when attempting to report a 500 error to the browser. Thanks to Giuseppe Monteleone for fixing the issue. 62 | 63 | ## 2.7.0 64 | 65 | * `distinct` and `distinct-counts` query parameters added. You must also configure `safeDistinct`. 66 | 67 | Thanks to Michelin for making this work possible via [Apostrophe Enterprise Support](https://apostrophecms.org/support/enterprise-support). 68 | 69 | ## 2.6.0 70 | 71 | * `includeFields` and `excludeFields` now work properly for joins. Thanks to Anthony Tarlao. 72 | 73 | ## 2.5.0 74 | 75 | * You may now specify just certain fields to be fetched with `includeFields` in your query string, or exclude certain fields with `excludeFields`. Thanks to `falkodev`. 76 | 77 | ## 2.4.0 78 | 79 | * You may now exclude a field from the GET method of the API entirely by setting `api: false` in its schema field definition. You may also set `api: 'editPermissionRequired'` to restrict access to that field to those who can edit the doc in question. Thanks to Anthony Tarlao. 80 | * If you would like to restrict GET access completely to those with edit permissions for the doc in question, you may now set the `getRequiresEditPermission` sub-option of `restApi` to `true`. Thanks again to Anthony Tarlao. 81 | 82 | ## 2.3.0 83 | 84 | * Support for the `PATCH` method, which allows you to send just the fields you want to change, with support for simple array operators as well. Thanks to Paul Grieselhuber for his support. 85 | 86 | ## 2.2.0 87 | 88 | * New `restApi.safeFilters` option (thanks to Marjan Georgiev), and documentation of the `restApi.maxPerPage` option. 89 | 90 | ## 2.1.2 91 | 92 | * Documentation changes only. Clarified that areas must be present in the schema to be inserted or updated via the API. 93 | 94 | ## 2.1.1 95 | 96 | * Fixed bug impacting the data provided by the `GET` route for pages when `all=1` is present. The data was incomplete due to missing query criteria. 97 | 98 | ## 2.1.0 99 | 100 | * Support for API keys, as a lightweight alternative to bearer tokens for server-to-server communication. These should not be compiled into mobile apps, i.e. anywhere users might be able to obtain them by decompiling, etc. 101 | * Support for pages, both reading and writing. 102 | * Support for fragment rendering, and documentation on how to get fully rendered versions. 103 | 104 | ## 2.0.4 105 | 106 | Documentation changes only. Gave some simple examples of query parameters that can be used to filter the results. 107 | 108 | ## 2.0.3 109 | 110 | `apostrophe-headless` no longer has to be configured before modules it adds APIs to, and it is possible to add APIs to `apostrophe-images` or `apostrophe-files` if desired. Thanks to Stephen Walsh for pointing out the issue. 111 | 112 | ## 2.0.2 113 | 114 | Documentation improvements. No code changes. 115 | 116 | ## 2.0.1 117 | 118 | Added CORS headers. This resolves any issues you may be having accessing the APIs from webpages served from a different host or port number. All modern and even not-so-modern browsers back to IE8 support this solution. 119 | 120 | ## 2.0.0 121 | 122 | Initial release. 123 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var _ = require('lodash'); 3 | var cuid = require('cuid'); 4 | var expressBearerToken = require('express-bearer-token'); 5 | var cors = require('cors'); 6 | 7 | module.exports = { 8 | 9 | moogBundle: { 10 | directory: 'lib/modules', 11 | modules: [ 'apostrophe-pieces-headless', 'apostrophe-pages-headless' ] 12 | }, 13 | 14 | afterConstruct: function(self, callback) { 15 | self.addRoutes(); 16 | if (self.options.bearerTokens || self.options.apiKeys) { 17 | self.apos.on('csrfExceptions', self.addCsrfException); 18 | } 19 | return self.enableCollection(callback); 20 | }, 21 | 22 | construct: function(self, options) { 23 | 24 | self.endpoint = '/api/v' + (options.version || 1); 25 | self.registeredModules = []; 26 | 27 | // Exclude the REST APIs from CSRF protection. However, 28 | // this module will call the CSRF protection middleware 29 | // itself if a user is not present based on a bearer token 30 | // or api key 31 | self.addCsrfException = function(exceptions) { 32 | exceptions.push(self.endpoint + '/**'); 33 | }; 34 | 35 | self.enableCollection = function(callback) { 36 | self.bearerTokensCollection = self.apos.db.collection('aposBearerTokens'); 37 | return self.bearerTokensCollection.ensureIndex({ expires: 1 }, { expireAfterSeconds: 0 }, callback); 38 | }; 39 | 40 | self.enableCorsHeaders = function() { 41 | const corsConfig = (typeof self.options.cors === 'object' && self.options.cors) || {}; 42 | self.apos.app.use(self.endpoint, cors(corsConfig)); 43 | }; 44 | 45 | self.addRoutes = function() { 46 | 47 | self.enableCorsHeaders(); 48 | 49 | if (self.options.bearerTokens) { 50 | self.apos.app.use(self.bearerMiddleware); 51 | self.apos.app.post(self.endpoint + '/login', function(req, res) { 52 | var bearer; 53 | var user; 54 | return async.series([ 55 | emitEventBeforeLogin, 56 | checkCredentials, 57 | insertToken 58 | ], function(err) { 59 | if (err) { 60 | return res.status((typeof (err) !== 'object') ? err : 500).send({ error: 'error' }); 61 | } else { 62 | return res.send({ bearer: bearer }); 63 | } 64 | }); 65 | // In the case of an async function, async.series will await 66 | // the resolution of the promise (including handling rejection) 67 | // and does not pass a callback 68 | async function emitEventBeforeLogin() { 69 | await self.emit('beforeLogin', req); 70 | } 71 | function checkCredentials(callback) { 72 | var username = self.apos.launder.string(req.body.username); 73 | var password = self.apos.launder.string(req.body.password); 74 | if (!(username && password)) { 75 | return callback(400); 76 | } 77 | return self.apos.login.verifyLogin(username, password, function(err, _user) { 78 | if (err) { 79 | return callback(err); 80 | } 81 | if (!_user) { 82 | return callback(401); 83 | } 84 | user = _user; 85 | return callback(null); 86 | }); 87 | } 88 | function insertToken(callback) { 89 | bearer = cuid(); 90 | return self.bearerTokensCollection.insert({ 91 | _id: bearer, 92 | userId: user._id, 93 | expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000) 94 | }, callback); 95 | } 96 | }); 97 | self.apos.app.post(self.endpoint + '/logout', function(req, res) { 98 | if (!req.user) { 99 | return res.status(403).send({ forbidden: 'forbidden' }); 100 | } 101 | return self.bearerTokensCollection.remove({ 102 | userId: req.user._id, 103 | _id: req.token 104 | }, function(err) { 105 | if (err) { 106 | return res.status(500).send({ error: 'error' }); 107 | } 108 | return res.send({}); 109 | }); 110 | }); 111 | } 112 | 113 | if (self.options.apiKeys) { 114 | self.apos.app.use(self.apiKeyMiddleware); 115 | } 116 | 117 | self.apos.app.use(self.endpoint, self.applyCsrfUnlessExemptMiddleware); 118 | 119 | self.apos.app.post(self.endpoint + '/attachments', self.apos.attachments.middleware.canUpload, self.apos.middleware.files, function(req, res) { 120 | var userAgent = req.headers['user-agent']; 121 | var matches = userAgent && userAgent.match(/MSIE (\d+)/); 122 | if (matches && (matches[1] <= 9)) { 123 | // Must use text/plain for file upload responses in IE <= 9, 124 | // don't do that to other browsers 125 | res.header('Content-Type', 'text/plain'); 126 | } 127 | // The name attribute could be anything because of how fileupload 128 | // controls work; we don't really care. 129 | var file = _.values(req.files || {})[0]; 130 | if (!file) { 131 | return res.status(400).send({ error: 'no file sent, did you forget to use multipart/form-data encoding?' }); 132 | } 133 | return self.apos.attachments.accept(req, file, function(err, file) { 134 | if (err) { 135 | self.apos.utils.error(err); 136 | return res.status(500).send({ status: 'error' }); 137 | } 138 | return res.send(file); 139 | }); 140 | }); 141 | 142 | }; 143 | 144 | self.applyCsrfUnlessExemptMiddleware = function(req, res, next) { 145 | if (req.csrfExempt) { 146 | return next(); 147 | } 148 | return self.apos.modules['apostrophe-express'].csrfWithoutExceptions(req, res, next); 149 | }; 150 | 151 | // Instantiate the express-bearer-token middleware for use 152 | // in parsing bearer tokens. Configuration may be passed to it via 153 | // the `expressBearerToken` option. 154 | self.bearerTokenMiddleware = expressBearerToken(self.options.expressBearerToken || {}); 155 | 156 | // The `bearerMiddleware` method is Express middleware 157 | // that detects a bearer token per RFC6750 and 158 | // sets `req.user` exactly as the `apostrophe-login` 159 | // module would. Extends the `express-bearer-token` 160 | // middleware to actually set `req.user`. If there 161 | // is no token or it is invalid we just don't set 162 | // `req.user` (it's an anonymous access). 163 | // 164 | // If a user is not assigned via a bearer token, 165 | // Apostrophe's standard CSRF middleware is invoked 166 | // to ensure that API accesses by logged-in website users 167 | // are not vulnerable to CSRF attacks. 168 | 169 | self.bearerMiddleware = function(req, res, next) { 170 | if (req.url.substr(0, self.endpoint.length + 1) !== (self.endpoint + '/')) { 171 | return next(); 172 | } 173 | if (req.url === self.endpoint + '/login') { 174 | // Login is exempt, chicken and egg 175 | return next(); 176 | } 177 | 178 | self.bearerTokenMiddleware(req, res, function() { 179 | var userId, user; 180 | if (!req.token) { 181 | return next(); 182 | } 183 | return async.series([ 184 | getBearer, 185 | deserializeUser 186 | ], function(err) { 187 | if (err) { 188 | self.apos.utils.error(err); 189 | return res.status(500).send({ error: 'error' }); 190 | } 191 | if (!user) { 192 | return res.status(401).send({ error: 'bearer token invalid' }); 193 | } 194 | req.csrfExempt = true; 195 | req.user = user; 196 | return next(); 197 | }); 198 | 199 | function getBearer(callback) { 200 | // The expireAfterSeconds feature of mongodb 201 | // is not instantaneous so we should check 202 | // "expires" ourselves too 203 | return self.bearerTokensCollection.findOne({ 204 | _id: req.token, 205 | expires: { $gte: new Date() } 206 | }, function(err, bearer) { 207 | if (err) { 208 | return callback(err); 209 | } 210 | userId = bearer && bearer.userId; 211 | return callback(null); 212 | }); 213 | } 214 | function deserializeUser(callback) { 215 | if (!userId) { 216 | return callback(null); 217 | } 218 | return self.apos.login.deserializeUser(userId, function(err, _user) { 219 | if (err) { 220 | return callback(err); 221 | } 222 | user = _user; 223 | return callback(null); 224 | }); 225 | } 226 | }); 227 | }; 228 | 229 | // Modules supporting the REST API call this method to register themselves 230 | // so that, for instance, module specific api keys can be checked 231 | self.registerModule = function(module) { 232 | self.registeredModules.push(module); 233 | }; 234 | 235 | self.apiKeyMiddleware = function(req, res, next) { 236 | 237 | if (req.url.substr(0, self.endpoint.length + 1) !== (self.endpoint + '/')) { 238 | return next(); 239 | } 240 | 241 | var key = req.query.apikey || req.query.apiKey || getAuthorizationApiKey(); 242 | var taskReq; 243 | if (!key) { 244 | return next(); 245 | } 246 | 247 | if (_.includes(self.options.apiKeys, key)) { 248 | taskReq = self.apos.tasks.getReq(); 249 | req.user = taskReq.user; 250 | req.csrfExempt = true; 251 | return next(); 252 | } else { 253 | var module = _.find(self.registeredModules, function(module) { 254 | return _.includes(module.options.apiKeys, key); 255 | }); 256 | if (module) { 257 | taskReq = self.apos.tasks.getReq(); 258 | req.user = taskReq.user; 259 | req.user._permissions = { 'edit-attachment': true }; 260 | // TODO this check would be better factored as a method 261 | // we call on the modules to get their effective type name 262 | if (module.__meta.name === 'apostrophe-pages') { 263 | req.user._permissions['admin-apostrophe-page'] = true; 264 | } else { 265 | req.user._permissions['admin-' + module.name] = true; 266 | } 267 | req.csrfExempt = true; 268 | return next(); 269 | } 270 | } 271 | 272 | return res.status(403).send({ error: 'invalid api key' }); 273 | 274 | function getAuthorizationApiKey() { 275 | var header = req.headers.authorization; 276 | if (!header) { 277 | return null; 278 | } 279 | var matches = header.match(/^ApiKey\s+(\S+)$/i); 280 | if (!matches) { 281 | return null; 282 | } 283 | return matches[1]; 284 | } 285 | 286 | }; 287 | 288 | // Given a module and API result object so far, render the doc 289 | // with the appropriate `api/` template of that module. 290 | // The template is called with `data.page` or `data.piece` 291 | // beig available depending on whether `name` is `page` or `piece`. 292 | 293 | self.apiRender = function(req, module, doc, name, callback) { 294 | var render = req.query.render; 295 | if (!render) { 296 | return callback(null); 297 | } 298 | // remove edit flags from widgets as that markup is 299 | // completely extraneous in an API response 300 | removeEditFlags(doc); 301 | if (!Array.isArray(render)) { 302 | render = [ render ]; 303 | } 304 | doc.rendered = {}; 305 | var bad = false; 306 | _.each(render, function(template) { 307 | template = self.apos.launder.string(template); 308 | if (!_.includes(module.options.apiTemplates, template)) { 309 | bad = true; 310 | return false; 311 | } 312 | var data = {}; 313 | data[name] = doc; 314 | doc.rendered[template] = module.render(req, 'api/' + template, data); 315 | }); 316 | if (bad) { 317 | return callback('badrequest'); 318 | } 319 | return callback(null); 320 | 321 | function removeEditFlags(doc) { 322 | if (Array.isArray(doc)) { 323 | _.each(doc, iterator); 324 | } else { 325 | _.forOwn(doc, iterator); 326 | } 327 | function iterator(val, key) { 328 | if (key === '_edit') { 329 | doc[key] = false; 330 | } 331 | if (typeof (val) === 'object') { 332 | removeEditFlags(val); 333 | } 334 | } 335 | } 336 | 337 | }; 338 | 339 | // Implementation detail, called for you by the PATCH route. 340 | // Applies changes in patch operators found in `patch` to set ordinary 341 | // properties of `patch`, referring to the doc `existing` to fill in 342 | // information like existing elements of arrays, etc. Then you call 343 | // convert normally with `patch ` as input and the schema indicated by 344 | // subsetSchemaForPatch. 345 | // 346 | // Includes support for the `$push`, `$pullAll`, and `$pullAllById` operators. 347 | 348 | self.implementPatchOperators = function(existing, patch) { 349 | if (patch.$push) { 350 | append(existing, patch.$push); 351 | } else if (patch.$pullAll) { 352 | _.each(patch.$pullAll, function(val, key) { 353 | _.set(patch, key, _.differenceWith(_.get(existing, key) || [], _.get(patch.$pullAll, key) || [], function(a, b) { 354 | return _.isEqual(a, b); 355 | })); 356 | }); 357 | } else if (patch.$pullAllById) { 358 | _.each(patch.$pullAllById, function(val, key) { 359 | _.set(patch, key, _.get(existing, key) || []); 360 | if (!Array.isArray(val)) { 361 | val = [ val ]; 362 | } 363 | _.set(patch, key, _.differenceWith(_.get(existing, key) || [], _.get(patch.$pullAllById, key), function(a, b) { 364 | return (a._id || a.id) === b; 365 | })); 366 | }); 367 | } 368 | function append(existing, data) { 369 | _.each(data, function(val, key) { 370 | _.set(patch, key, _.get(existing, key) || []); 371 | if (val && val.$each) { 372 | _.set(patch, key, (_.get(patch, key) || []).concat(val.$each)); 373 | } else { 374 | var _existing = _.get(patch, key) || []; 375 | _existing.push(val); 376 | _.set(patch, key, _existing); 377 | } 378 | }); 379 | } 380 | }; 381 | 382 | // Given a `doc` containing patch operators like `$push`, return a subset 383 | // of `schema` containing the root fields that would ultimately be updated by 384 | // those operations. 385 | 386 | self.subsetSchemaForPatch = function(schema, doc) { 387 | var idFields = {}; 388 | schema.forEach(function(field) { 389 | if ((field.type === 'joinByOne') || (field.type === 'joinByArray')) { 390 | idFields[field.idField || field.idsField] = field.name; 391 | } 392 | }); 393 | return self.apos.schemas.subset(schema, _.map(_.keys(doc).concat(operatorKeys()), idFieldToSchemaField)); 394 | function operatorKeys() { 395 | return _.uniq(_.flatten( 396 | _.map([ '$push', '$pullAll', '$pullAllById' ], function(o) { 397 | return _.map(_.keys(doc[o] || {}), function(key) { 398 | return key.toString().split(/\./)[0]; 399 | }); 400 | }) 401 | )); 402 | } 403 | function idFieldToSchemaField(name) { 404 | return idFields[name] || name; 405 | } 406 | }; 407 | 408 | } 409 | 410 | }; 411 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-pieces-headless/index.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var _ = require('lodash'); 3 | 4 | module.exports = { 5 | 6 | improve: 'apostrophe-pieces', 7 | 8 | construct: function(self, options) { 9 | 10 | self.addRestApiRoutes = function() { 11 | var restApi = self.apos.modules['apostrophe-headless']; 12 | if ((!options.restApi) || (options.restApi.enabled === false)) { 13 | return; 14 | } 15 | var baseEndpoint = restApi.endpoint; 16 | var endpoint = baseEndpoint + '/' + (options.restApi.name || self.__meta.name); 17 | 18 | // GET many 19 | self.apos.app.get(endpoint, function(req, res) { 20 | var cursor = self.findForRestApi(req); 21 | var result = {}; 22 | return async.series([ distinct, countPieces, findPieces, renderPieces ], function(err) { 23 | if (err) { 24 | self.apos.utils.error(err); 25 | return res.status(500).send({ error: 'error' }); 26 | } 27 | return res.send(result); 28 | }); 29 | 30 | function distinct(callback) { 31 | var distinct = self.apos.launder.string(req.query.distinct).split(','); 32 | var counts = self.apos.launder.string(req.query['distinct-counts']).split(','); 33 | if (distinct[0] === '') { 34 | distinct = []; 35 | } 36 | if (counts[0] === '') { 37 | counts = []; 38 | } 39 | return async.eachSeries(_.uniq(distinct.concat(counts)), function(filter, callback) { 40 | if (!_.includes(self.options.restApi.safeDistinct || [], filter)) { 41 | return callback(null); 42 | } 43 | var counted = _.includes(counts, filter); 44 | var _cursor = cursor.clone(); 45 | _cursor[filter](undefined); 46 | return _cursor.toChoices(filter, { counts: counted }, function(err, choices) { 47 | if (err) { 48 | return callback(err); 49 | } 50 | result.distinct = result.distinct || {}; 51 | result.distinct[filter] = choices; 52 | return callback(null); 53 | }); 54 | }, callback); 55 | } 56 | 57 | function countPieces(callback) { 58 | return cursor.toCount(function(err, count) { 59 | if (err) { 60 | return callback(err); 61 | } 62 | result.total = count; 63 | result.pages = cursor.get('totalPages'); 64 | result.perPage = cursor.get('perPage'); 65 | result.currentPage = cursor.get('page') || 1; 66 | return callback(null); 67 | }); 68 | } 69 | 70 | function findPieces(callback) { 71 | return cursor.toArray(function(err, pieces) { 72 | if (err) { 73 | return callback(err); 74 | } 75 | // Attach `_url` and `_urls` properties 76 | self.apos.attachments.all(pieces, { annotate: true }); 77 | pieces.forEach(function(piece) { 78 | self.restFilterFields(req, piece); 79 | }); 80 | result.results = pieces; 81 | return callback(null); 82 | }); 83 | } 84 | 85 | function renderPieces(callback) { 86 | return async.eachSeries(result.results, function(piece, callback) { 87 | var restApi = self.apos.modules['apostrophe-headless']; 88 | return restApi.apiRender(req, self, piece, 'piece', callback); 89 | }, callback); 90 | } 91 | 92 | }); 93 | 94 | // GET one 95 | self.apos.app.get(endpoint + '/:id', function(req, res) { 96 | var id = self.apos.launder.id(req.params.id); 97 | if (!id) { 98 | return res.status(400).send({ error: 'bad request' }); 99 | } 100 | var piece; 101 | return async.series([ find, render ], function(err) { 102 | if (err) { 103 | if (err === 'notfound') { 104 | return res.status(404).send({ error: 'notfound' }); 105 | } else { 106 | self.apos.utils.error(err); 107 | return res.status(500).send({ error: 'error' }); 108 | } 109 | } 110 | return res.send(piece); 111 | }); 112 | function find(callback) { 113 | return self.findForRestApi(req).and({ _id: id }).toObject(function(err, _piece) { 114 | if (err) { 115 | return callback('error'); 116 | } 117 | if (!_piece) { 118 | return callback('notfound'); 119 | } 120 | piece = _piece; 121 | self.restFilterFields(req, piece); 122 | // Attach `_url` and `_urls` properties 123 | self.apos.attachments.all(piece, { annotate: true }); 124 | return callback(null); 125 | }); 126 | } 127 | function render(callback) { 128 | var restApi = self.apos.modules['apostrophe-headless']; 129 | return restApi.apiRender(req, self, piece, 'piece', callback); 130 | } 131 | }); 132 | 133 | // POST one 134 | self.apos.app.post(endpoint, function(req, res) { 135 | var keys = Object.keys(req.body); 136 | if (_.includes(keys, 'title') && (!_.includes(keys, 'slug'))) { 137 | // Let the slug be inferred from the title properly 138 | keys.push('slug'); 139 | } 140 | req.convertOnlyTheseFields = keys; 141 | return self.convertInsertAndRefresh(req, function(req, res, err, piece) { 142 | if (err) { 143 | return res.status(500).send({ error: 'error' }); 144 | } 145 | return res.send(piece); 146 | }); 147 | }); 148 | 149 | // Update (PUT) one piece. The body must contain all of the properties 150 | // of the document as found in the schema otherwise they are 151 | // set blank. If this is not what you want, use the PATCH method. 152 | 153 | self.apos.app.put(endpoint + '/:id', function(req, res) { 154 | var id = self.apos.launder.id(req.params.id); 155 | return self.restPutOrPatch(req, id, 'put'); 156 | }); 157 | 158 | // PATCH one piece. Only touches properties present in the request; 159 | // also supports MongoDB-style `$push`, `$pullAll` and `$pullAllById` operators, 160 | // with a subset of the features found in MongoDB. 161 | // 162 | // PATCH operations are atomic with respect to other PATCH operations. 163 | // 164 | // PATCH operations may append to arrays using the 165 | // following syntax: 166 | // 167 | // `$push: { addresses: { street: '123 Wiggle Street' } }` 168 | // 169 | // The value given for `addresses` is appended to the existing 170 | // `addresses` schema field as a single element, even if it is itself 171 | // an array. This can be changed using the `$each` option: 172 | // 173 | // `$push: { addresses: { $each: [ { street: '123 Wiggle Street' }, { street: '101 Wacky Lane' } ] } }` 174 | // 175 | // Dot notation may be used to access arrays in subproperties with this 176 | // syntax. 177 | // 178 | // `$pullAll` may be used to remove all matching values present 179 | // for the array in question: 180 | // 181 | // `$pullAll: { addresses: { [ { street: '101 Wacky Lane', id: 'abcdef' } ] } }` 182 | // 183 | // If the array property in question is an 184 | // area's `items` property or an array schema field's value, it is more 185 | // convenient to remove array elements by their `id` or `_id` property: 186 | // 187 | // `$pullAllById`: 'abcdef' 188 | // `$pullAllById`: [ 'abcdef', 'qwerty' ] 189 | // 190 | // Note that this will match on either an `_id` property or an `-id` property. 191 | // 192 | // These operators can also be used to update the `idsField` 193 | // of a join. For instance, if a `joinByArray` field is named 194 | // `_people`, and `idsField` has not been set to the contrary, 195 | // you can append the `_id`s of additional people 196 | // to the `peopleIds` property using `$addToSet`. 197 | // 198 | // `patch` calls are guaranteed to be atomic with regard to 199 | // other `patch` operations. That is, if two `patch` operations 200 | // run concurrently updating different properties, all of the 201 | // property updates are guaranteed to make it through. If 202 | // two `patch` operations attempt to `$push` to the same 203 | // array, the first to begin will append its items first. 204 | 205 | self.apos.app.patch(endpoint + '/:id', function(req, res) { 206 | var id = self.apos.launder.id(req.params.id); 207 | return self.restPutOrPatch(req, id, 'patch'); 208 | }); 209 | 210 | self.restFilterFields = function(req, piece) { 211 | if (!piece._edit) { 212 | // Filter out editPermissions properties, where appropriate 213 | self.schema.forEach(function(field) { 214 | if (field.api === 'editPermissionRequired') { 215 | delete piece[field.name]; 216 | } 217 | }); 218 | // If you can't edit, it is none of your business who else can 219 | delete piece.docPermissions; 220 | } 221 | // To avoid situations that confuse developers, such as joins not 222 | // working or _url not populating, some fields like tags or type are 223 | // not excluded at the mongo level. Instead, delete them before 224 | // transmission 225 | _.each(req.excludeFields, function(field) { 226 | delete piece[field]; 227 | }); 228 | }; 229 | 230 | // Implementation detail of the PUT and PATCH methods 231 | // (see above). 232 | // 233 | // Patch or put the given piece. The new data should be in `req.body` and 234 | // will be applied to the existing piece specified by `id` if 235 | // the permissions of `req` permit. If `action` is `patch`, only the 236 | // schema fields actually present in the `piece` object are touched, 237 | // otherwise all schema fields are touched, with absence treated 238 | // as an attempt to set an empty value for that property. 239 | 240 | self.restPutOrPatch = function(req, id, action) { 241 | if (action === 'patch') { 242 | return self.apos.locks.withLock('apostrophe-headless-' + id, body, respond); 243 | } else { 244 | return body(respond); 245 | } 246 | 247 | function respond(err) { 248 | if (err) { 249 | if (err === 'notfound') { 250 | return req.res.status(404).send({ error: err }); 251 | } else if (err === 'invalid') { 252 | return req.res.status(400).send({ error: err }); 253 | } else { 254 | return req.res.status(500).send({ error: 'error' }); 255 | } 256 | } 257 | return req.res.send(req.piece); 258 | } 259 | 260 | function body(callback) { 261 | return self.findForEditing(req, { _id: id }) 262 | .toObject(function(err, _piece) { 263 | if (err) { 264 | return callback(err); 265 | } 266 | if (!_piece) { 267 | return callback('notfound'); 268 | } 269 | req.piece = _piece; 270 | if (action === 'patch') { 271 | var restApi = self.apos.modules['apostrophe-headless']; 272 | restApi.implementPatchOperators(_piece, req.body); 273 | req.restApiPatchSchema = restApi.subsetSchemaForPatch(self.schema, req.body); 274 | } else { 275 | req.convertOnlyTheseFields = Object.keys(req.body); 276 | } 277 | return self.convertUpdateAndRefresh(req, function(req, res, err, _piece) { 278 | return callback(err); 279 | }); 280 | } 281 | ); 282 | } 283 | 284 | }; 285 | 286 | // DELETE one 287 | self.apos.app.delete(endpoint + '/:id', function(req, res) { 288 | var id = self.apos.launder.id(req.params.id); 289 | return async.series({ 290 | before: function(callback) { 291 | return self.beforeTrash(req, id, callback); 292 | }, 293 | trash: function(callback) { 294 | return self.trash(req, id, callback); 295 | }, 296 | after: function(callback) { 297 | return self.afterTrash(req, id, callback); 298 | } 299 | }, function(err) { 300 | if (err) { 301 | return res.status(500).send({ error: 'error' }); 302 | } 303 | return res.send({}); 304 | }); 305 | }); 306 | 307 | }; 308 | 309 | var superAllowedSchema = self.allowedSchema; 310 | self.allowedSchema = function(req) { 311 | var schema = superAllowedSchema(req); 312 | if (req.restApiPatchSchema) { 313 | schema = _.intersectionBy(schema, req.restApiPatchSchema, 'name'); 314 | } 315 | return schema; 316 | }; 317 | 318 | self.findForRestApi = function(req) { 319 | var which = 'public'; 320 | var projection = {}; 321 | var includeFromQuery = false; 322 | var joins = []; 323 | var joinsToExclude = []; 324 | 325 | if (req.query._workflowLocale) { 326 | // We don't use req.query.workflowLocale because that is caught by the 327 | // workflow middleware and would cause a redirect that is not useful here 328 | req.locale = req.query._workflowLocale; 329 | } 330 | 331 | if (req.query.includeFields) { 332 | // Always retrieve information necessary to annotate 333 | // what is editable and what is not, calculate _url, etc. 334 | projection.docPermissions = 1; 335 | projection.type = 1; 336 | projection.slug = 1; 337 | projection._id = 1; 338 | projection.tags = 1; 339 | var includeFields = self.apos.launder.string(req.query.includeFields).split(','); 340 | includeFields.forEach(function(field) { 341 | projection[field] = 1; 342 | includeFromQuery = true; 343 | }); 344 | } 345 | 346 | if (self.apos.permissions.can(req, 'edit-' + self.name)) { 347 | which = 'manage'; 348 | } 349 | 350 | self.schema.forEach(function(field) { 351 | if (field.api === false) { 352 | removeKey(field); 353 | } 354 | if (field.type.match(/join/)) { 355 | joins.push(field.name); 356 | } else if (field.schema) { 357 | field.schema.forEach(function(subField) { 358 | if (subField.type.match(/join/)) { 359 | joins.push(field.name + '.' + subField.name); 360 | } 361 | }); 362 | } 363 | }); 364 | 365 | if (!includeFromQuery && req.query.excludeFields) { 366 | req.excludeFields = self.apos.launder.string(req.query.excludeFields).split(','); 367 | req.excludeFields.forEach(function(field) { 368 | // Excluding these fields via mongodb has side effects on joins and _url that are 369 | // rarely anticipated by developers. We will delete them after the fetch 370 | if ((field !== 'type') && (field !== '_id') && (field !== 'tags') && (field !== 'slug')) { 371 | projection[field] = 0; 372 | } 373 | }); 374 | } 375 | 376 | // add "exclude" fields only if "includeFields" fields are not required, 377 | // because Mongo cannot handle both at the same time 378 | function removeKey(field) { 379 | if ( 380 | includeFromQuery || 381 | Object.prototype.hasOwnProperty.call(projection, field.name) 382 | ) { 383 | delete projection[field.name]; 384 | if (field.type.match(/join/)) { 385 | joinsToExclude.push(field.name); 386 | } 387 | } else { 388 | projection[field.name] = 0; 389 | if (field.type.match(/join/)) { 390 | if (field.relationship) { 391 | projection[field.relationshipsField] = 0; 392 | } 393 | if (field.idsField) { 394 | projection[field.idsField] = 0; 395 | } 396 | if (field.idField) { 397 | projection[field.idField] = 0; 398 | } 399 | } 400 | } 401 | 402 | return projection; 403 | } 404 | 405 | var joinsToInclude = _.difference(joins, joinsToExclude); 406 | var cursor = self.find(req, {}) 407 | .projection(projection) 408 | .joins(joinsToInclude) 409 | .safeFilters((options.restApi.safeFilters || []).concat(options.restApi.safeDistinct || [])) 410 | .queryToFilters(req.query, which); 411 | 412 | if (options.restApi.getRequiresEditPermission) { 413 | cursor.permission('edit'); 414 | } 415 | 416 | var perPage = cursor.get('perPage'); 417 | var maxPerPage = options.restApi.maxPerPage || 50; 418 | if ((!perPage) || (perPage > maxPerPage)) { 419 | cursor.perPage(maxPerPage); 420 | } 421 | return cursor; 422 | }; 423 | 424 | self.modulesReady = function() { 425 | var restApi = self.apos.modules['apostrophe-headless']; 426 | self.addRestApiRoutes(); 427 | restApi.registerModule(self); 428 | }; 429 | } 430 | }; 431 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-pages-headless/index.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var _ = require('lodash'); 3 | 4 | module.exports = { 5 | 6 | improve: 'apostrophe-pages', 7 | 8 | construct: function(self, options) { 9 | 10 | self.addRestApiRoutes = function() { 11 | var restApi = self.apos.modules['apostrophe-headless']; 12 | if ((!options.restApi) || (options.restApi.enabled === false)) { 13 | return; 14 | } 15 | var baseEndpoint = restApi.endpoint; 16 | var endpoint = baseEndpoint + '/' + (options.restApi.name || self.__meta.name); 17 | 18 | // GET home or tree 19 | self.apos.app.get(endpoint, function(req, res) { 20 | var all = self.apos.launder.boolean(req.query.all); 21 | var flat = self.apos.launder.boolean(req.query.flat); 22 | if (all) { 23 | if (!self.apos.permissions.can(req, 'admin-apostrophe-page')) { 24 | return res.status(403).send({ error: 'forbidden' }); 25 | } 26 | // TODO lifted far too much code from the jqtree route, 27 | // refactor to share code. However note property name differences 28 | return self.findForRestApi(req).and({ level: 0 }).children({ 29 | depth: 1000, 30 | published: null, 31 | trash: false, 32 | orphan: null, 33 | joins: false, 34 | areas: false, 35 | permission: false 36 | }).toObject(function(err, page) { 37 | if (err) { 38 | self.apos.utils.error(err); 39 | return res.status(500).send({ error: 'error' }); 40 | } 41 | 42 | if (!page) { 43 | return res.status(404).send({ error: 'notfound' }); 44 | } 45 | 46 | var data = [ page ]; 47 | 48 | // Prune pages we can't reorganize 49 | data = clean(data); 50 | if (flat) { 51 | var result = []; 52 | flatten(result, data[0]); 53 | return res.send(result); 54 | } 55 | return res.send(data[0]); 56 | 57 | // If I can't publish at least one of a node's 58 | // descendants, prune it from the tree. Returns 59 | // a pruned version of the tree 60 | 61 | function clean(nodes) { 62 | mark(nodes, []); 63 | return prune(nodes); 64 | function mark(nodes, ancestors) { 65 | _.each(nodes, function(node) { 66 | if (node._publish) { 67 | node.good = true; 68 | _.each(ancestors, function(ancestor) { 69 | ancestor.good = true; 70 | }); 71 | } 72 | mark(node._children || [], ancestors.concat([ node ])); 73 | }); 74 | } 75 | function prune(nodes) { 76 | var newNodes = []; 77 | _.each(nodes, function(node) { 78 | node._children = prune(node._children || []); 79 | if (node.good) { 80 | newNodes.push(_.pick(node, 'title', 'slug', '_id', 'type', 'tags', '_url', '_children')); 81 | } 82 | }); 83 | return newNodes; 84 | } 85 | 86 | } 87 | function flatten(result, node) { 88 | var children = node._children; 89 | node._children = _.map(node._children, '_id'); 90 | result.push(node); 91 | _.each(children || [], function(child) { 92 | flatten(result, child); 93 | }); 94 | } 95 | }); 96 | 97 | } 98 | var result; 99 | return async.series([ findPages, render ], function(err) { 100 | if (err) { 101 | self.apos.utils.error(err); 102 | return res.status(500).send({ error: 'error' }); 103 | } 104 | return res.send(result); 105 | }); 106 | 107 | function findPages(callback) { 108 | return self.findForRestApi(req).and({ level: 0 }).toObject(function(err, home) { 109 | if (err) { 110 | return callback(err); 111 | } 112 | // Attach `_url` and `_urls` properties 113 | self.apos.attachments.all(home, { annotate: true }); 114 | result = home; 115 | return callback(null); 116 | }); 117 | } 118 | 119 | function render(callback) { 120 | var restApi = self.apos.modules['apostrophe-headless']; 121 | return restApi.apiRender(req, self, result, 'page', callback); 122 | } 123 | 124 | }); 125 | 126 | // GET one 127 | self.apos.app.get(endpoint + '/:id', function(req, res) { 128 | var id = self.apos.launder.id(req.params.id); 129 | if (!id) { 130 | return res.status(400).send({ error: 'invalid' }); 131 | } 132 | var page; 133 | return async.series([ find, render ], function(err) { 134 | if (err === 'notfound') { 135 | return res.status(404).send({ error: 'notfound' }); 136 | } else if (err === 'badrequest') { 137 | return res.status(400).send({ error: 'badrequest' }); 138 | } else if (err) { 139 | return res.status(500).send({ error: 'error' }); 140 | } 141 | return res.send(page); 142 | }); 143 | function find(callback) { 144 | return self.findForRestApi(req).and({ _id: id }).toObject(function(err, _page) { 145 | if (err) { 146 | return callback(err); 147 | } 148 | if (!_page) { 149 | return callback('notfound'); 150 | } 151 | page = _page; 152 | // Attach `_url` and `_urls` properties 153 | self.apos.attachments.all(page, { annotate: true }); 154 | return callback(null); 155 | }); 156 | } 157 | function render(callback) { 158 | var restApi = self.apos.modules['apostrophe-headless']; 159 | return restApi.apiRender(req, self, page, 'page', callback); 160 | } 161 | }); 162 | 163 | // POST one 164 | self.apos.app.post(endpoint, function(req, res) { 165 | // Derived from the implementation of the insert route. 166 | // TODO: refactor in core so they share as much of this code as 167 | // possible 168 | var parentId = self.apos.launder.id(req.body._parentId); 169 | var page = _.omit(req.body, 'parentId'); 170 | if (typeof (page) !== 'object') { 171 | // cheeky 172 | return res.status(400).send({ error: 'bad request' }); 173 | } 174 | var parentPage; 175 | var safePage; 176 | return async.series({ 177 | findParent: function(callback) { 178 | return self.find(req, { _id: parentId }).permission('publish-apostrophe-page').toObject(function(err, _parentPage) { 179 | if (err) { 180 | return callback(err); 181 | } 182 | if (!_parentPage) { 183 | return callback('notfound'); 184 | } 185 | parentPage = _parentPage; 186 | safePage = self.newChild(parentPage); 187 | return callback(null); 188 | }); 189 | }, 190 | convert: function(callback) { 191 | var manager = self.apos.docs.getManager(self.apos.launder.string(page.type)); 192 | if (!manager) { 193 | // sneaky 194 | return callback('notfound'); 195 | } 196 | // Base the allowed schema on a generic new child of the parent page, not 197 | // random untrusted stuff from the browser 198 | var schema = manager.allowedSchema(req); 199 | var keys = Object.keys(page); 200 | if (_.includes(keys, 'title') && (!_.includes(keys, 'slug'))) { 201 | // Let the slug be inferred from the title properly 202 | keys.push('slug'); 203 | } 204 | schema = self.apos.schemas.subset(schema, keys); 205 | return self.apos.schemas.convert(req, schema, 'form', page, safePage, callback); 206 | }, 207 | insert: function(callback) { 208 | return self.insert(req, parentPage, safePage, callback); 209 | }, 210 | find: function(callback) { 211 | // Fetch the page. Yes, we already have it, but this way all the cursor 212 | // filters run and we have access to ._url 213 | return self.find(req, { _id: safePage._id }).published(null).toObject(function(err, _safePage) { 214 | if (err) { 215 | return callback(err); 216 | } 217 | if (!_safePage) { 218 | return callback('notfound'); 219 | } 220 | safePage = _safePage; 221 | self.apos.attachments.all(safePage, { annotate: true }); 222 | return callback(null); 223 | }); 224 | } 225 | }, function(err) { 226 | if (err) { 227 | self.apos.utils.error(err); 228 | if (err === 'notfound') { 229 | return res.status(404).send({ error: err }); 230 | } else if (err === 'invalid') { 231 | return res.status(400).send({ error: err }); 232 | } else { 233 | return res.status(500).send({ error: 'error' }); 234 | } 235 | } 236 | return res.send(safePage); 237 | }); 238 | }); 239 | 240 | // Update (PUT) one page. The body must contain all of the properties 241 | // of the document as found in the schema otherwise they are 242 | // set blank. If this is not what you want, use the PATCH method. 243 | 244 | self.apos.app.put(endpoint + '/:id', function(req, res) { 245 | // TODO: too much code borrowed from core update route, 246 | // refactor to share most of it 247 | var id = self.apos.launder.id(req.params.id); 248 | var page = req.body || {}; 249 | if (typeof (page) !== 'object') { 250 | // cheeky 251 | return res.status(400).send({ error: 'invalid' }); 252 | } 253 | return self.restPutOrPatch(req, id, page, 'put'); 254 | }); 255 | 256 | // PATCH one page. Only touches properties present in the request; 257 | // also supports MongoDB-style `$push`, `$pullAll` and `$pullAllById` operators, 258 | // with a subset of the features found in MongoDB. 259 | // 260 | // PATCH operations are atomic with respect to other PATCH operations. 261 | // 262 | // PATCH operations may append to arrays using the 263 | // following syntax: 264 | // 265 | // `$push: { addresses: { street: '123 Wiggle Street' } }` 266 | // 267 | // The value given for `addresses` is appended to the existing 268 | // `addresses` schema field as a single element, even if it is itself 269 | // an array. This can be changed using the `$each` option: 270 | // 271 | // `$push: { addresses: { $each: [ { street: '123 Wiggle Street' }, { street: '101 Wacky Lane' } ] } }` 272 | // 273 | // Dot notation may be used to access arrays in subproperties with this 274 | // syntax. 275 | // 276 | // `$pullAll` may be used to remove all matching values present 277 | // for the array in question: 278 | // 279 | // `$pullAll: { addresses: { [ { street: '101 Wacky Lane', id: 'abcdef' } ] } }` 280 | // 281 | // If the array property in question is an 282 | // area's `items` property or an array schema field's value, it is more 283 | // convenient to remove array elements by their `id` or `_id` property: 284 | // 285 | // `$pullAllById`: 'abcdef' 286 | // `$pullAllById`: [ 'abcdef', 'qwerty' ] 287 | // 288 | // Note that this will match on either an `_id` property or an `-id` property. 289 | // 290 | // These operators can also be used to update the `idsField` 291 | // of a join. For instance, if a `joinByArray` field is named 292 | // `_people`, and `idsField` has not been set to the contrary, 293 | // you can append the `_id`s of additional people 294 | // to the `peopleIds` property using `$push`. 295 | // 296 | // `patch` calls are guaranteed to be atomic with regard to 297 | // other `patch` operations. That is, if two `patch` operations 298 | // run concurrently updating different properties, all of the 299 | // property updates are guaranteed to make it through. If 300 | // two `patch` operations attempt to `$push` to the same 301 | // array, the first to begin will append its items first. 302 | 303 | self.apos.app.patch(endpoint + '/:id', function(req, res) { 304 | // TODO: too much code borrowed from core update route, 305 | // refactor to share most of it 306 | var id = self.apos.launder.id(req.params.id); 307 | var page = req.body || {}; 308 | if (typeof (page) !== 'object') { 309 | // cheeky 310 | return res.status(400).send({ error: 'invalid' }); 311 | } 312 | return self.restPutOrPatch(req, id, page, 'patch'); 313 | }); 314 | 315 | // Implementation detail of the PUT and PATCH methods 316 | // (see above). 317 | // 318 | // Patch or update the given page. `page` is the data from the browser, 319 | // which will be applied to the existing page specified by `id` if 320 | // the permissions of `req` permit. If `action` is `patch`, only the 321 | // schema fields actually present in the `page` object are touched, 322 | // otherwise all schema fields are touched, with absence created 323 | // as an attempt to set an empty value for that property. 324 | 325 | self.restPutOrPatch = function(req, id, page, action) { 326 | var existingPage; 327 | 328 | if (action === 'patch') { 329 | return self.apos.locks.withLock('apostrophe-headless-' + id, body, respond); 330 | } else { 331 | return body(respond); 332 | } 333 | 334 | function respond(err) { 335 | if (err) { 336 | self.apos.utils.error(err); 337 | if (err === 'notfound') { 338 | return req.res.status(404).send({ error: err }); 339 | } else if (err === 'invalid') { 340 | return req.res.status(400).send({ error: err }); 341 | } else { 342 | return req.res.status(500).send({ error: 'error' }); 343 | } 344 | } 345 | return req.res.send(existingPage); 346 | } 347 | 348 | function body(callback) { 349 | return async.series({ 350 | find: function(callback) { 351 | return self.find(req, { _id: id }).permission('edit-apostrophe-page').trash(self.apos.docs.trashInSchema ? null : false).toObject(function(err, _page) { 352 | if (err) { 353 | return callback(err); 354 | } 355 | if (!_page) { 356 | return callback('notfound'); 357 | } 358 | existingPage = _page; 359 | return callback(null); 360 | }); 361 | }, 362 | convert: function(callback) { 363 | var restApi = self.apos.modules['apostrophe-headless']; 364 | var manager = self.apos.docs.getManager(self.apos.launder.string(page.type || existingPage.type)); 365 | if (!manager) { 366 | // sneaky 367 | return callback('notfound'); 368 | } 369 | var schema = manager.allowedSchema(req); 370 | schema = self.addApplyToSubpagesToSchema(schema); 371 | schema = self.removeParkedPropertiesFromSchema(existingPage, schema); 372 | if (action === 'patch') { 373 | schema = restApi.subsetSchemaForPatch(schema, page); 374 | restApi.implementPatchOperators(existingPage, page); 375 | } else { 376 | // overwrite only fields that are in the schema 377 | schema = self.apos.schemas.subset(schema, Object.keys(page)); 378 | } 379 | return self.apos.schemas.convert(req, schema, 'form', page, existingPage, callback); 380 | }, 381 | update: function(callback) { 382 | return self.update(req, existingPage, callback); 383 | }, 384 | findAgain: function(callback) { 385 | // Fetch the page. Yes, we already have it, but this way all the cursor 386 | // filters run and we have access to ._url 387 | return self.find(req, { _id: existingPage._id }).published(null).trash(self.apos.docs.trashInSchema ? null : false).toObject(function(err, _page) { 388 | if (err) { 389 | return callback(err); 390 | } 391 | if (!_page) { 392 | return callback('notfound'); 393 | } 394 | existingPage = _page; 395 | return callback(null); 396 | }); 397 | } 398 | }, callback); 399 | } 400 | }; 401 | 402 | // DELETE one 403 | self.apos.app.delete(endpoint + '/:id', function(req, res) { 404 | var id = self.apos.launder.id(req.params.id); 405 | return self.moveToTrash(req, id, function(err, parentSlug, changed) { 406 | if (err) { 407 | if (err === 'notfound') { 408 | return res.status(404).send({ error: 'notfound' }); 409 | } else if (err === 'forbidden') { 410 | return res.status(403).send({ error: 'notfound' }); 411 | } else { 412 | return res.status(500).send({ error: 'error' }); 413 | } 414 | } 415 | return res.send({}); 416 | }); 417 | }); 418 | 419 | // Move page 420 | self.apos.app.post(endpoint + '/:id/move', function(req, res) { 421 | var id = self.apos.launder.id(req.params.id); 422 | var targetId = self.apos.launder.id(req.body.targetId); 423 | var position = self.apos.launder.string(req.body.position); 424 | return self.move(req, id, targetId, position, function(err, parentSlug, changed) { 425 | if (err) { 426 | self.apos.utils.error(err); 427 | if (err === 'notfound') { 428 | return res.status(404).send({ error: 'notfound' }); 429 | } else if (err === 'forbidden') { 430 | return res.status(403).send({ error: 'notfound' }); 431 | } else { 432 | return res.status(500).send({ error: 'error' }); 433 | } 434 | } 435 | return res.send({}); 436 | }); 437 | }); 438 | 439 | }; 440 | 441 | self.findForRestApi = function(req) { 442 | if (req.query._workflowLocale) { 443 | // We don't use req.query.workflowLocale because that is caught by the 444 | // workflow middleware and would cause a redirect that is not useful here 445 | req.locale = req.query._workflowLocale; 446 | } 447 | // Allow children and ancestors to be optional. 448 | var includeChildren = self.apos.launder.boolean(req.query.children, true); 449 | var includeAncestors = self.apos.launder.boolean(req.query.ancestors, true); 450 | 451 | var cursor = self 452 | .find(req) 453 | .ancestors(includeAncestors) 454 | .children(includeChildren) 455 | .published(null); 456 | 457 | if (options.restApi.getRequiresEditPermission) { 458 | cursor.permission('edit'); 459 | } 460 | return cursor; 461 | }; 462 | 463 | var superModulesReady = self.modulesReady; 464 | self.modulesReady = function(callback) { 465 | return superModulesReady(function(err) { 466 | if (err) { 467 | return callback(err); 468 | } 469 | var restApi = self.apos.modules['apostrophe-headless']; 470 | self.addRestApiRoutes(); 471 | restApi.registerModule(self); 472 | return callback(null); 473 | }); 474 | }; 475 | } 476 | }; 477 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⛔️ **DEPRECATED** — do not use for new projects 2 | 3 | See [our current docs](https://docs.apostrophecms.org/) 4 | 5 | [![CircleCI](https://circleci.com/gh/apostrophecms/apostrophe-headless/tree/master.svg?style=svg)](https://circleci.com/gh/apostrophecms/apostrophe-headless/tree/master) 6 | 7 | ## Apostrophe as a headless CMS 8 | 9 | [Apostrophe](http://apostrophecms.org) is great for building websites, but many projects these days just need a "headless" CMS: an easy way to create new content types by defining schemas and immediately have a friendly interface for managing them on the back end... and REST APIs on the front end for React, React Native and other frontend frameworks to talk to. 10 | 11 | Just as often, projects call for a mix of the two: Apostrophe as a CMS for the pages of the site, with React-style apps "mixed in" on certain pages. 12 | 13 | The `apostrophe-headless` module provides REST APIs for content types created with Apostrophe's [pieces](https://docs.apostrophecms.org/core-concepts/reusable-content-pieces/reusable-content-with-pieces.html) feature. With this module, you might choose to just click "Page Settings" and lock down the "home page" of your site to "logged in users only," then use Apostrophe as a pure headless CMS... or you might mix and match. It's up to you. 14 | 15 | > We'll start out by talking about pieces, because they map so well to REST concepts. But `apostrophe-headless` also supports working with pages. We recommend you read about pieces first to figure out the basics, especially authentication. 16 | 17 | ## Adding a REST API for products 18 | 19 | Let's assume you have a module called `products` that extends `apostrophe-pieces` as described in our [reusable content with pieces](https://docs.apostrophecms.org/core-concepts/reusable-content-pieces/reusable-content-with-pieces.html) tutorial. Now you want a REST API so your app can easily get information about pieces. 20 | 21 | ## Install the package 22 | 23 | ``` 24 | npm install apostrophe-headless 25 | ``` 26 | 27 | ## Turn it on 28 | 29 | ```javascript 30 | // in app.js 31 | modules: { 32 | 33 | 'apostrophe-headless': {}, 34 | 35 | 'products': { 36 | // Usually you'll put most of this in lib/products/index.js 37 | extend: 'apostrophe-pieces', 38 | name: 'product', 39 | // etc... 40 | restApi: true 41 | } 42 | } 43 | ``` 44 | 45 | ### Configuration options 46 | 47 | You can also pass options for the REST API: 48 | 49 | ```javascript 50 | 'products': { 51 | // etc... 52 | restApi: { 53 | // max 50 pieces per API result (the default) 54 | maxPerPage: 50, 55 | // Allow the public API to invoke additional 56 | // cursor filters. Note that most schema 57 | // fields have a cursor filter available 58 | safeFilters: [ 'slug' ], 59 | // Restrict GET routes to users with correct permission (false by default) 60 | getRequiresEditPermission: true 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | > Setting `maxPerPage` high can have performance impacts. Consider designing your app with pagination or infinite scroll in mind rather than fetching thousands of pieces the user will not actually look at. 67 | 68 | > All of the documentation below discusses the `products` example above. Of course you may also configure the `restApi` option for other modules that extend pieces. 69 | 70 | ## Workflow support 71 | 72 | When used in the presence of the `apostrophe-workflow`, this module currently only supports GET requests. When making GET requests, you must specify the locale name of interest using the `_workflowLocale` query parameter, otherwise you will get results for the default locale only. 73 | 74 | Note that this is not an issue if the document type in question is excluded from workflow via `excludeTypes`. 75 | 76 | 77 | ## Retrieving all the products 78 | 79 | Now your app can access: 80 | 81 | `/api/v1/products` 82 | 83 | To get the first page of products (50 per page, unless `maxPerPage` is adjusted as shown above). The response is JSON. See the `results` property for an array of products included in the first page, and the `pages` property for the total number of pages. 84 | 85 | If you want to fetch a second page of products: 86 | 87 | `/api/v1/products?page=2` 88 | 89 | To avoid performance issues we do not send more than 50 products per API call. Your app should make additional queries as needed. 90 | 91 | ### Filtering products 92 | 93 | Here are some examples: 94 | 95 | `/api/v1/products?search=cheese` 96 | 97 | `/api/v1/products?autocomplete=che` 98 | 99 | There's much more. You can use any [s filter](https://docs.apostrophecms.org/advanced-topics/database/cursors.html) that offers a `sanitize` method via the query string. It's [not hard to add custom filters](https://docs.apostrophecms.org/advanced-topics/database/cursors.html#custom-filters) if you need to, but keep in mind that most schema field types have built-in [filter support](https://docs.apostrophecms.org/advanced-topics/database/cursors.html#built-in-filters-every-schema-field-gets-one). 100 | 101 | To call most filters from the public API, you will need to use the `safeFilters` option to declare these filters "safe.". Rather than just `restApi: true`, write: 102 | 103 | ```javascript 104 | 'my-module': { 105 | restApi: { 106 | // We're assuming here that you have added fields 107 | // called 'color' and 'brand' in your schema 108 | safeFilters: [ 'slug', 'color', 'brand' ] 109 | } 110 | } 111 | ``` 112 | 113 | > You may filter `joinByOne` and `joinByArray` fields, as long as they are listed in `safeFilters`. When doing so pass the `_id` property. Alternatively, leave the leading `_` off the field name and pass the `slug` property. 114 | 115 | ### Filtering fields for all requests 116 | 117 | You can restrict what fields to send by adding `api: false` to a specific schema field. If only users with editing permissions for the doc should see a specific field, you can pass the option `api: 'editPermissionRequired'`. 118 | 119 | ```javascript 120 | { 121 | name: 'specificField', 122 | label: 'Specific Field', 123 | type: 'string', 124 | api: false 125 | } 126 | ``` 127 | 128 | ### Filtering fields for a single request 129 | 130 | You can also require only specific fields in the GET request by adding the query filters `includeFields` and `excludeFields`. 131 | 132 | Examples: 133 | 134 | `/api/v1/products?includeFields=type,slug,name` 135 | 136 | ``` 137 | // response example 138 | [ 139 | { 140 | _id: 'whatever_id', 141 | type: 'product', 142 | slug: 'product-key-product' 143 | } 144 | ] 145 | ``` 146 | 147 | The response will contain only `_id`, `type`, `slug` and (if present) `name`. 148 | 149 | `/api/v1/products?excludeFields=type,slug,name` 150 | 151 | ``` 152 | // response example 153 | [ 154 | { 155 | _id: 'whatever_id', 156 | title: 'Product', 157 | body: { ... } 158 | } 159 | ] 160 | ``` 161 | 162 | The response will contain everything *except* `type`, `slug` and `name`. 163 | 164 | > It is useless to use both `includeFields` and `excludeFields` in the same query, as `includeFields` has priority over `excludeFields`. This is due to the way MongoDB projections work. 165 | 166 | If there is any conflict between the `api` schema field option above and the `includeFields` option, the `api` schema field option takes priority. 167 | 168 | ## Retrieving distinct tags, joins, etc. to populate your filters 169 | 170 | In addition to fetching actual pieces, you can obtain information about the distinct tags that may exist on those pieces, as well as information about the distinct objects that are joined to them. 171 | 172 | This is useful to populate your filters. For instance, to allow the user to filter the results by tag **without prefetching every result in the database to scan for tags**, you must know what tags exist. 173 | 174 | To add information about distinct tags to the response, first configure your module to allow it: 175 | 176 | ```javascript 177 | // in lib/modules/products/index.js 178 | 'products': { 179 | extend: 'apostrophe-pieces', 180 | name: 'product', 181 | restApi: { 182 | safeDistinct: [ 'tags' ] 183 | } 184 | } 185 | ``` 186 | 187 | > Without `safeDistinct`, developers would be able to cause a denial of service by requesting all distinct values at once for fields like `_id` that are always different. 188 | 189 | Now, you may access URLs like this: 190 | 191 | `/api/v1/products?distinct=tags` 192 | 193 | The response will look like: 194 | 195 | ```javascript 196 | { 197 | results: [ ... pieces here ], 198 | distinct: { 199 | tags: [ 200 | { 201 | label: 'Free', 202 | value: 'Free' 203 | }, 204 | { 205 | label: 'Paid', 206 | value: 'Paid' 207 | } 208 | ] 209 | } 210 | } 211 | ``` 212 | 213 | Now we can display the labels to our users, and if they pick one, send back the value in the `tags` query parameter: 214 | 215 | `/api/v1/products?tags=Paid` 216 | 217 | **Since the distinct values are intended for use as filters, use of `safeDistinct` implies `safeFilter` as well.** You don't have to specify both for the same filter. 218 | 219 | > You can pass multiple values for `tags`, with or without the familiar `[]`, syntax, for example: `tags[]=one&tags[]=two` 220 | > You'll get results that include at least one of the tags. 221 | 222 | ### Distinct values for joins 223 | 224 | Now let's assume there is a `joinByOne` schema field called `_specialist` that joins our `product` piece with a `specialist` piece. We can fetch distinct values here too. In this case, the `value` property will be the `_id`: 225 | 226 | ```javascript 227 | // in lib/modules/products/index.js 228 | 229 | 'products': { 230 | name: 'product', 231 | extend: 'apostrophe-pieces', 232 | addFields: [ 233 | { 234 | type: 'joinByOne', 235 | name: '_specialist' 236 | } 237 | ], 238 | restApi: { 239 | safeDistinct: [ '_specialist' ] 240 | } 241 | } 242 | ``` 243 | 244 | Then we can access: 245 | 246 | `/api/v1/products?distinct=_specialist` 247 | 248 | The response will look like: 249 | 250 | ```javascript 251 | { 252 | results: [ ... pieces here ], 253 | distinct: { 254 | _specialist: [ 255 | { 256 | label: 'Jane Doe', 257 | value: '_cyyyy' 258 | }, 259 | { 260 | label: 'Joe Smith', 261 | value: '_czzzz' 262 | } 263 | ] 264 | } 265 | } 266 | ``` 267 | 268 | Once again we can display the labels to our users, and if they pick one, send back the value in the `_specialist` query parameter: 269 | 270 | `/api/v1/products?_specialist=_cyyyy` 271 | 272 | > We send the value, NOT the label. Again, you can send more than one by passing more than one `_specialist` query parameter. You'll get results that include at least one of the specialists. 273 | 274 | ### Adding counts for each distinct value 275 | 276 | Want to show the user how many items are tagged `Free` as part of your filter interface? You can do that by using `distinct-counts` in place of `distinct`. Keep in mind that **the answer will still be in the `distinct` object**; however, each choice will now have a `count` property in addition to `label` and `value`. 277 | 278 | Example request: 279 | 280 | `/api/v1/products?distinct-counts=tags` 281 | 282 | Example response: 283 | 284 | ```javascript 285 | { 286 | results: [ ... pieces here ], 287 | distinct: { 288 | tags: [ 289 | { 290 | label: 'Free', 291 | value: 'Free', 292 | count: 5 293 | }, 294 | ... More tags here 295 | ] 296 | } 297 | } 298 | ``` 299 | 300 | 301 | ### Distinct values for more than one filter 302 | 303 | Yes, this is supported. Just use comma-separated field names when passing `distinct` or `counts` in your URL. 304 | 305 | For example, you might make this request: 306 | 307 | `/api/v1/products?distinct=_specialist,tags` 308 | 309 | In which case the `distinct` property of the response will have both `_specialist` and `tags` subproperties. 310 | 311 | Make sure both `_specialist` and `tags` are configured as `safeDistinct`: 312 | 313 | ```javascript 314 | // in lib/modules/products/index.js 315 | 316 | 'products': { 317 | name: 'product', 318 | extend: 'apostrophe-pieces', 319 | addFields: [ 320 | { 321 | type: 'joinByOne', 322 | name: '_specialist' 323 | } 324 | ], 325 | restApi: { 326 | safeDistinct: [ '_specialist', 'tags' ] 327 | } 328 | } 329 | ``` 330 | 331 | ### Access as a logged-in user 332 | 333 | If you are accessing the API as a user who can edit this piece type, you can use all cursor filters intended for web use, otherwise only the filters marked `safeFor: 'public'`. 334 | 335 | ## Retrieving one product 336 | 337 | You can also retrieve one product via its `_id` property: 338 | 339 | `/api/v1/products/cxxxxxxx` 340 | 341 | The response is a single JSON object containing the product. 342 | 343 | Even though you are fetching just one product, you can still invoke filters via the query string. If you are carrying out this request with the privileges of an admin user, you might want to add `?published=any` to gain access to an unpublished product. 344 | 345 | ## Inserting, updating and deleting products 346 | 347 | These operations follow the usual REST patterns. But first, we need to talk about permissions. 348 | 349 | ## Invoking APIs when logged out 350 | 351 | This is simple: if the user is not logged in, they will be able to `GET` public, published content, and that's all. 352 | 353 | For many apps, **that's fine. You're using Apostrophe's admin bar to create the content anyway.** 354 | 355 | Your content editors log into a site that's just for content creation, and your app users pull content from it via REST APIs. Great! **You're done here.** 356 | 357 | But for those who need to create and manage content via REST too... read on! 358 | 359 | ## Invoking REST APIs as a logged-in user of your Apostrophe site 360 | 361 | If you're building a React app or similar that is part of a webpage delivered by your Apostrophe site, and the right user is already logged into the site, then the APIs will automatically "see" the user and run with the right permissions. However, see the note that follows re: CSRF protection. 362 | 363 | > If this doesn't sound relevant to your project, skip ahead to learn how to use API keys and bearer tokens instead. We've got your back, headless horseman. 364 | 365 | ## CSRF protection and logged-in users 366 | 367 | **If an API request comes from an Apostrophe user who logged in conventionally via the website,** and not via the REST login APIs below, then Apostrophe will check for CSRF (Cross-Site Request Forgery) attacks. 368 | 369 | If your API request is being sent by jQuery as provided by Apostrophe, you're good to go: Apostrophe automatically adds the necessary header. 370 | 371 | If your API request is sent via `fetch` or another alternative to jQuery, you'll need to set the `X-XSRF-TOKEN` HTTP header to the current value of `window.apos.csrfCookieName`. This ensures the request didn't come from a sneaky form on a third-party website. 372 | 373 | ## Adding CORS config 374 | 375 | It's also possible to add a specific CORS configuration for headless routes. Note that this does not secure your routes against the use of scripts, `curl`, etc. It only prevents well-behaved browsers like Chrome from making unwanted cross-site requests. 376 | See available options [here](https://github.com/expressjs/cors#configuration-options) 377 | 378 | ```javascript 379 | 'apostrophe-headless': { 380 | cors: { 381 | // CORS options 382 | } 383 | }, 384 | ``` 385 | 386 | ## Building apps without Apostrophe UI: bearer tokens and API keys 387 | 388 | By default, the `POST`, `DELETE` and `PUT` APIs are available to logged-in users of the site. This is quite useful if you want to provide some editing features in a React or similar app that is part of your Apostrophe site. 389 | 390 | But for a standalone app that uses Apostrophe as a headless backend, and isn't part of your Apostrophe site in any other way, logging in via Apostrophe's interface might not be an option. 391 | 392 | For such cases, you can log in via REST and obtain a "bearer token" to be sent with requests. Or, you can use a hardcoded API key with total admin access. We'll look at API keys first, to help you get started. Then we'll look at bearer tokens. 393 | 394 | ### Working with API keys 395 | 396 | It's easy to configure API keys to have **full admin access to all content** for which the REST API has been activated: 397 | 398 | ```javascript 399 | // in app.js 400 | modules: { 401 | 'apostrophe-headless': { 402 | apiKeys: [ 'example-i-sure-hope-you-changed-this' ] 403 | }, 404 | products: { 405 | extend: 'apostrophe-pieces', 406 | name: 'product', 407 | restApi: true 408 | }, 409 | locations: { 410 | extend: 'apostrophe-pieces', 411 | name: 'location', 412 | restApi: true 413 | } 414 | } 415 | ``` 416 | 417 | You can also configure api keys for a single module: 418 | 419 | ```javascript 420 | // in app.js 421 | modules: { 422 | 'apostrophe-headless': { 423 | // This option MUST EXIST to allow api keys at all. If you 424 | // do not want any global api keys, leave it empty 425 | apiKeys: [] 426 | }, 427 | products: { 428 | extend: 'apostrophe-pieces', 429 | name: 'product', 430 | restApi: true, 431 | apiKeys: [ 'i-only-grant-access-to-this-one-module' ] 432 | }, 433 | locations: { 434 | extend: 'apostrophe-pieces', 435 | name: 'location', 436 | restApi: true 437 | } 438 | } 439 | ``` 440 | 441 | > Either way, the api key is allowed to create attachments (see [Images, files and attachments in REST](#images-files-and-attachments-in-rest)). 442 | 443 | Now you can pass the API key in either of two ways when [inserting a product](#inserting-a-product) or making a similar request: 444 | 445 | 1. Just add an `apikey` property to the query string. **This goes in the query string regardless of the request method.** 446 | 447 | Example: 448 | 449 | `POST /api/v1/products?apikey=example-api-key` 450 | 451 | The body of the POST may be a JSON body or use the traditional url encoding, as described below; the important thing is that the apikey is separate, in the query string, as shown here. 452 | 453 | 2. Pass an `Authorization` header as part of your HTTP request: 454 | 455 | `Authorization: ApiKey your-api-key-goes-here` 456 | 457 | > **Always secure sites that accept API keys with HTTPS.** You should never send an API key over "plain HTTP." Of course, browsers are starting to deprecate sites that don't accept HTTPS anyway! 458 | 459 | #### When NOT to use API keys 460 | 461 | API keys are useful for hardcoded situations where **there is no way an untrusted user could ever see them.** For instance, it's fine to use an API key for back-end communication between two servers. 462 | 463 | However, you should **never use api keys in the code of a mobile app, browser-based web app, JavaScript in the browser of any kind** or other situation where code might be viewed as source, decompiled, etc. In these situations, you must use bearer tokens, which are specific to a user. 464 | 465 | ### Using bearer tokens 466 | 467 | Bearer tokens are a way to let users log in even though they never see an Apostrophe-powered website. They allow you to implement your own login mechanism in your mobile app. 468 | 469 | > Using bearer tokens only makes sense if you are using Apostrophe as your authentication system. If you are using `apostrophe-passport` to connect Apostrophe to google login, Twitter login, etc., you'll need to log users in via the Apostrophe site and then deliver your app via a stripped-down Apostrophe "home page" on that site. See the notes above re: working smoothly with our CSRF protection in this configuration. 470 | 471 | #### How to log users in with bearer tokens 472 | 473 | 1. Turn on support for bearer tokens: 474 | 475 | ```javascript 476 | // in app.js 477 | modules: { 478 | 'apostrophe-headless': { 479 | bearerTokens: true 480 | } 481 | } 482 | ``` 483 | 484 | By default bearer tokens last 2 weeks, which is very secure but can be frustrating for casual apps that don't contain sensitive data. Here's how to set the bearer token lifetime: 485 | 486 | ```javascript 487 | // in app.js 488 | modules: { 489 | 'apostrophe-headless': { 490 | bearerTokens: { 491 | // 4 weeks, in seconds 492 | lifetime: 86400 * 7 * 4 493 | } 494 | } 495 | } 496 | ``` 497 | 498 | 2. Send a `POST` request to: 499 | 500 | `/api/v1/login` 501 | 502 | With `username` and `password` properties in the body. 503 | 504 | 3. On success, you will receive a JSON object with a single property: `bearer`. 505 | 506 | 4. For all of the REST API calls that follow, pass that value as the `Authorization` header, preceded by `Bearer` and a space: 507 | 508 | `Bearer nnnn` 509 | 510 | Where `nnnn` should be replaced with the value of the `bearer` property you received. 511 | 512 | There is **no need to pass the XSRF header** when using a valid bearer token because bearer tokens are never part of an Apostrophe session. 513 | 514 | 5. If you receive a `401 Unauthorized` response to a later API request, consider making another `login` call to obtain a new bearer token. The expiration of bearer tokens depends on the `expires` setting as shown earlier. 515 | 516 | 6. If the user logs out of your app, send a POST request as follows: 517 | 518 | `/api/v1/logout` 519 | 520 | With the appropriate `Bearer` heading as for any other request. That bearer token will be invalidated. 521 | 522 | > **Always secure sites that accept bearer tokens with HTTPS.** Of course, browsers are starting to deprecate sites that don't accept HTTPS anyway! 523 | > 524 | > **If you submit an invalid or outdated bearer token for any request**, you will receive a `401` HTTP status, and a JSON object with an `error` property set to `'bearer token invalid'`. This is your cue to ask the user to log in again and then retry the request. 525 | 526 | ## Login events 527 | 528 | You can find an `apostrophe-headless:beforeLogin` promise event which is emitted with (req) before a login attempt is evaluated. 529 | 530 | ## Inserting a product 531 | 532 | You can insert a product via a POST request. You should POST to: 533 | 534 | `/api/v1/products` 535 | 536 | The body of your POST should contain all of the schema fields you wish to set. 537 | 538 | You may use either traditional URL-style encoding or a JSON body. **However if you are working with Apostrophe areas you must use a JSON body** (see below). 539 | 540 | On success you will receive a 200 status code and a JSON object containing the new product. 541 | 542 | ## Updating a product 543 | 544 | To update a product **completely, sending all the data again**, make a PUT request. Send it to: 545 | 546 | `/api/v1/products/cxxxxxxx` 547 | 548 | Where `cxxxxxxx` is the `_id` property of the existing product you wish to update. 549 | 550 | On success you will receive a 200 status code and the updated JSON object representing the product. 551 | 552 | You may use either traditional URL-style encoding or a JSON body. **However if you are working with Apostrophe areas you must use a JSON body** (see below). 553 | 554 | > If you want to update just SOME of the properties, without the risk that some of your other data is incomplete or out of date, use PATCH (see below). 555 | 556 | ## Patching a product 557 | 558 | To patch a product **partially, sending only the changes**, make a `PATCH` request. Send it to: 559 | 560 | `/api/v1/products/cxxxxxxx` 561 | 562 | Where `cxxxxxxx` is the `_id` property of the existing product you wish to patch. Use the `PATCH` HTTP method. 563 | 564 | Include only the properties you wish to change. If a property is present in your request body, it will be updated. If it is present, but empty, it will be updated to an empty value, which may or may not be accepted depending on your schema. 565 | 566 | On success you will receive a 200 status code and the updated JSON object representing the entire product. 567 | 568 | You may use either traditional URL-style encoding or a JSON body. **However if you are working with Apostrophe areas you must use a JSON body** (see below). 569 | 570 | ### Patching just part of an array property 571 | 572 | You may also `PATCH` an array property without re-sending the entire array. `apostrophe-headless` supports several operators based on the MongoDB operators of the same name. 573 | 574 | > To use this feature, you MUST use a JSON body, not traditional URL-style encoding. 575 | 576 | If your schema includes this field: 577 | 578 | ```javascript 579 | { 580 | name: 'addresses', 581 | type: 'array', 582 | schema: [ 583 | { 584 | name: 'street', 585 | type: 'string' 586 | } 587 | ] 588 | } 589 | ``` 590 | 591 | Then you may carry out the following operations: 592 | 593 | #### `$push`: append one 594 | 595 | ```javascript 596 | { 597 | $push: { 598 | addresses: { 599 | street: '103 Test Lane' 600 | } 601 | } 602 | } 603 | ``` 604 | 605 | #### `$push` with `$each`: append many 606 | 607 | ```javascript 608 | { 609 | $push: { 610 | addresses: { 611 | $each: [ 612 | { 613 | street: '104 Test Lane' 614 | }, 615 | { 616 | street: '105 Test Lane' 617 | }, 618 | { 619 | street: '106 Test Lane' 620 | }, 621 | ] 622 | } 623 | } 624 | } 625 | ``` 626 | 627 | ### `$pullAll`: remove array entries matching complete value 628 | 629 | ```javascript 630 | { 631 | $pullAll: { 632 | addresses: [ addresses[0] ] 633 | } 634 | } 635 | ``` 636 | 637 | ### `$pullAllById`: remove array entries matching `id` or `_id` property 638 | 639 | ```javascript 640 | $pullAllById: { 641 | addresses: [ addresses[0].id ] 642 | } 643 | ``` 644 | 645 | > "But where do I get `addresses[0].id` from?" Typically from an earlier `GET` or `POST` operation. 646 | 647 | > Array operators can be used to manipulate `array` schema fields, the widget array of an area, or the `idsField` of a join. 648 | 649 | ## Deleting a product 650 | 651 | To delete a product, make a DELETE request. Send it to: 652 | 653 | `/api/v1/products/cxxxxxxx` 654 | 655 | Where `cxxxxxxx` is the `_id` property of the existing product you wish to delete. 656 | 657 | The response will be an appropriate HTTP status code. 658 | 659 | ## Inserting areas and widgets via REST 660 | 661 | Given how powerful they are, [areas and widgets](https://docs.apostrophecms.org/core-concepts/editable-content-on-pages/#editable-content-on-pages-with-widgets) in Apostrophe are surprisingly easy to work with via the REST API. 662 | 663 | Just bear these facts in mind: 664 | 665 | * Singletons are just areas restricted to one widget of a specified type when edited via the website. There's no difference in the database, and none in your API calls. So everything you read below applies to them too. 666 | * An area is just a property of the piece. It is an object with a `type` property equal to `area`, and an `items` array containing the widgets that make up the area. 667 | * Each widget in the area must have a unique `id` property (we recommend that you use the `cuid` npm module like we do), and a `type` property set to the name of the widget. That is, if it comes from the `people-widgets` module, the `type` property will just be `people`. 668 | * Other properties are specific to each widget type, based on its schema. It's often helpful to use the MongoDB shell to investigate a few examples in your site's database. 669 | * Rich text widgets contain markup in a `content` property. 670 | * Array schema fields have `type: "array"` and an `items` array containing their content. Each item must have a unique `id` property. 671 | * **You must fully specify your areas and singletons in the schema of your piece type or page type,** including passing all the options you would otherwise pass in a template. Since templates are not in play there would otherwise be no validation of appropriate widget types. 672 | 673 | Here's an example of a simple area containing a standard `apostrophe-rich-text` widget, a "nav" widget specific to a particular site which contains an `array` schema field, and a standard `apostrophe-images` widget: 674 | 675 | ```javascript 676 | body: { 677 | type: 'area', 678 | items: [ 679 | { 680 | id: 'cxxxxx1', 681 | type: 'apostrophe-rich-text', 682 | content: '

Subheading

Here is some text.

' 683 | }, 684 | { 685 | id: 'cxxxxx2', 686 | type: 'nav', 687 | links: { 688 | type: 'array', 689 | items: [ 690 | { 691 | id: 'cxxxxx3', 692 | url: 'http://cnn.com', 693 | label: 'CNN' 694 | }, 695 | { 696 | id: 'cxxxxx4', 697 | url: 'http://google.com', 698 | label: 'Google' 699 | }, 700 | ] 701 | } 702 | }, 703 | { 704 | id: 'cxxxxx5', 705 | type: 'apostrophe-images', 706 | by: 'id', 707 | pieceIds: [ 'imageid1', 'imageid2' ] 708 | } 709 | ] 710 | } 711 | ``` 712 | 713 | We'll see how `pieceIds` works in the `apostrophe-images` widget in a moment when we discuss images, files and attachments in REST. 714 | 715 | ## Joins in REST 716 | 717 | When retrieving pieces, joined content is included, via the join field's name, as you might expect. 718 | 719 | When inserting or updating pieces, it is possible to set a join. You will need to set the `idField` (for `joinByOne`) or `idsField` (for `joinByArray`) corresponding to the join. If you did not explicitly configure these when configuring the join in your schema, they are based on the name of the join: 720 | 721 | `_stores` -> `storeIds` 722 | 723 | `_owner` -> `ownerId` 724 | 725 | etc. Set that property to the appropriate ID or array of IDs. 726 | 727 | ## Images, files and attachments in REST 728 | 729 | It is possible to attach files to a new or updated piece. To do so you will first need to understand how attachments work in Apostrophe. In most cases, you'll also need understand how `apostrophe-images` and `apostrophe-files` widgets work. 730 | 731 | ### Attachment fields 732 | 733 | `attachment` is a special schema field type. Ideally, files attached to a piece would live right inside it. However since files are large and it does not make sense to resend the same file every time you update a piece, you will instead need to first send Apostrophe the file and obtain an attachment object. You can then use that attachment object as the value of any field of type `attachment`. Think of the attachment as a "pointer" to the real file on disk. 734 | 735 | To send an attachment, POST a file (using the `multipart/form-data` encoding) to the following URL: 736 | 737 | `/api/v1/attachments` 738 | 739 | Send the actual file as the `file` field in your form submission. 740 | 741 | > The user POSTing the attachments must have the `edit-attachment` permission. POST is currently the only method provided for attachments. 742 | 743 | On success, you will receive a JSON object containing properties similar to these: 744 | 745 | ``` 746 | { 747 | _id: 'attachmentidnnnn', 748 | width: 500, 749 | height: 400, 750 | group: 'images', 751 | extension: 'jpg', 752 | name: 'cleaned-up-name-without-extension' 753 | } 754 | ``` 755 | 756 | > The `content-type` of the response will be `text/plain`, for backwards compatibility with certain browsers, but it will contain valid JSON. 757 | 758 | **You can now send this object as the value of any `attachment` schema field when communicating with the REST API.** 759 | 760 | ### Using attachments directly 761 | 762 | If you're doing most of your editing through the REST API, or your content types don't really need a shared image library from which images can be chosen by the end user, you might just add a schema field like this in your module: 763 | 764 | ```javascript 765 | addFields: [ 766 | { 767 | type: 'attachment', 768 | name: 'snapshot', 769 | // Accepts only images. Can also specify `office` 770 | // to accept workplace document formats 771 | groups: [ 'images' ] 772 | } 773 | ] 774 | ``` 775 | 776 | Then you can simply pass the `file` object you received from the attachments API as the `snapshot` property when POSTing a product. 777 | 778 | > Later, when you `GET` this product from the API, you'll note that the attachment has a `._urls` property with versions of various sizes for your use. To make those URLs absolute, set the `baseUrl` option for your site in `app.js`. This is a top-level option, like `shortName`. It does not belong to a specific module. It should be set to the URL of your site, without any path part. In production, that might look like `http://example.com` while in development, it might look like: `http://localhost:3000` 779 | 780 | ### Working with the shared media library 781 | 782 | Sometimes, you'll want to introduce an image to the shared media library of Apostrophe and reference it via an images widget. Here's how to do that. 783 | 784 | ### Working with `apostrophe-images` and `apostrophe-files` 785 | 786 | Often you'll use a widget of type `apostrophe-images` or `apostrophe-files` to display a slideshow of images, or a download button for a file. This allows the user to choose them from a shared media library. If you're doing at least some of your editing through Apostrophe then this is an attractive option. 787 | 788 | So if you want to create these widgets with the REST API, you'll need to first use the technique above to create an attachment. 789 | 790 | > Here we're assuming a `singleton` field called `thumbnail` containing an `apostrophe-images` widget is part of your schema for `projects`. In the database, both areas and singletons are simply stored as areas. The only difference is that the end user can't put more than one widget in a singleton via the editor. 791 | 792 | So, **make sure you turn on the REST API for `apostrophe-images` too.** Images are pieces in their own right: 793 | 794 | ```javascript 795 | // in app.js 796 | modules: { 797 | 'apostrophe-images': { 798 | restApi: true 799 | }, 800 | // etc 801 | } 802 | ``` 803 | 804 | > Note that the user POSTing these images must have `edit` permission for both images *and* products. 805 | 806 | Now, POST to `/api/v1/apostrophe-images`. You'll need to supply at least `title`, `slug`, and `attachment`. The `attachment` field must contain the `file` object you received from the attachment upload API, above. 807 | 808 | > Just set `attachment` to `result`, where `result` is the JSON object you got back from the upload API. 809 | 810 | You will receive a JSON object in response. Using the `_id` property, you can create a project that includes that file in an images widget, in an area called `thumbnail`. POST an object like this to `/api/v1/projects` to create a project with a thumbnail: 811 | 812 | ```javascript 813 | { 814 | title: 'My Project', 815 | slug: 'my-project', 816 | thumbnail: { 817 | type: 'area', 818 | items: [ 819 | { 820 | type: 'apostrophe-images', 821 | by: 'id', 822 | pieceIds: [ yourImageId ] 823 | } 824 | ] 825 | } 826 | } 827 | ``` 828 | 829 | Set `yourImageId` to the `_id` of the object you received when you POSTed to `/api/v1/apostrophe-images`. 830 | 831 | ## Working with pages 832 | 833 | The examples above all concern pieces. Pieces are the most natural candidate for a REST API, but you can also use `apostrophe-headless` to work with pages: 834 | 835 | ```javascript 836 | modules: { 837 | 838 | 'apostrophe-headless': {}, 839 | 840 | 'apostrophe-pages': { 841 | restApi: true 842 | } 843 | } 844 | ``` 845 | 846 | ## Retrieving the home page and its children 847 | 848 | Now your app can access: 849 | 850 | `/api/v1/apostrophe-pages` 851 | 852 | To get information about the home page and its children. The response is a single JSON object with `slug`, `path`, `title`, `type`, `_url` and other properties describing the home page, similar to the way pieces are returned (see the "products" examples above). In addition, information about children of the home page is returned. 853 | 854 | ### Accessing child pages 855 | 856 | Basic information about the top-level children of the home page (aka the "tabs" of your site) is available in the `_children` property of the returned object. This property is an array. Each element has, at a minimum, `_id`, `title`, `type` and `slug` properties. 857 | 858 | By default, the `_children` property always exists. It may be empty. You can disable this property for a smaller response by including the query parameter, `children=false`. 859 | 860 | ### Fetching detailed information about one page 861 | 862 | Armed with the `_id`, you can obtain detailed information about a page by making a separate API request: 863 | 864 | `/api/v1/apostrophe-pages/ID_GOES_HERE` 865 | 866 | A page returned in this way will in turn offer its own `_children` property. 867 | 868 | This response will include schema fields, areas, etc. in the same detail as it would when requesting a piece. 869 | 870 | ### Accessing ancestor pages 871 | 872 | Pages also have an `_ancestors` array. This functions similarly to the `_children` array. The first entry is the home page, and the last entry is the immediate parent of the page in question. 873 | 874 | By default, the `_ancestors` property also always exists. It may be empty. You can disable `_ancestors` for a smaller response by including the query parameter, `ancestors=false`. 875 | 876 | ### Obtaining the entire page tree with a single request 877 | 878 | It is possible to obtain summary information about the entire page tree with a single request. Since the unrestricted use of this feature could have a performance impact, **This feature requires a bearer token or API key**. 879 | 880 | > If a bearer token is used, the returned tree will not contain pages to which the user does not have edit access, except for ancestors of pages to which the user *does* have edit access, which is necessary to accurately present the tree. 881 | 882 | To fetch the entire tree, add `all=1` to your query: 883 | 884 | `/api/v1/apostrophe-pages?all=1` 885 | 886 | ### Nested tree response 887 | 888 | The response will be a single object representing the home page, with at least `title`, `slug`, `tags`, `_url` and `_id` properties, and a `_children` array. For speed, the response will not be as detailed as in a regular request to `/api/v1/apostrophe-pages`. 889 | 890 | The pages in the `_children` array, in turn, will feature their own `_children` arrays where needed, with a similarly limited level of detail. 891 | 892 | ### Flat response 893 | 894 | It is possible to obtain a flat version of this data by adding `?flat=1` to the URL. In this case, a flat JSON array is returned. The array is sorted by depth, then by rank. Pages may still have a `_children` array, however it will only contain the `_id`s of the child pages, not the pages themselves. In this way you can still reconstruct the tree if you wish. 895 | 896 | ## Inserting a page 897 | 898 | **All write operations to pages are governed by permissions.** See ["invoking APIs when logged out,"](#invoking-apis-when-logged-out) above. You will need to use an API key or bearer token. 899 | 900 | It is possible to insert a page via the API: 901 | 902 | `/api/v1/apostrophe-pages` 903 | 904 | The body of your POST should contain all of the schema fields you wish to set, **and in addition it must contain a `_parentId` property** (note the underscore). The page will be added as the last child of the specified parent page. 905 | 906 | **The use of a JSON body, rather than traditional URL encoding, is strongly recommended and if you are working with areas it is mandatory.** 907 | 908 | On success you will receive a 200 status code and a JSON object containing the new page. 909 | 910 | **If you wish to insert or update areas, they must be present in the schema of the page type.** 911 | 912 | ## Updating a page 913 | 914 | To update a product, make a PUT request. Send it to: 915 | 916 | `/api/v1/apostrophe-pages/cxxxxxxx` 917 | 918 | Where `cxxxxxxx` is the `_id` property of the existing page you wish to update. 919 | 920 | On success you will receive a 200 status code and the updated JSON object representing the product. 921 | 922 | You may use either traditional URL-style encoding or a JSON body. **However if you are working with Apostrophe areas you must use a JSON body** (see below). 923 | 924 | **You may not move a page in the page tree via this method. The `path`, `level` and `rank` properties cannot be modified by this method.** To move a page in the page tree, see ["moving a page in the page tree,"](#moving-a-page-in-the-page-tree) below. 925 | 926 | **If you wish to insert or update areas, they must be present in the schema of the page type.** 927 | 928 | ## Deleting a page 929 | 930 | To delete a page, make a DELETE request. Send it to: 931 | 932 | `/api/v1/apostrophe-pages/cxxxxxxx` 933 | 934 | Where `cxxxxxxx` is the `_id` property of the existing page you wish to delete. 935 | 936 | The response will be an appropriate HTTP status code. 937 | 938 | *For consistency with the rest of Apostrophe, a deleted page is moved to the trash.* 939 | 940 | ## Moving a page in the page tree 941 | 942 | To move a page in the page tree, make a POST request to the following URL: 943 | 944 | `/api/v1/apostrophe-pages/ID-OF-PAGE/move` 945 | 946 | Your POST body must contain the following fields: 947 | 948 | * `targetId` must be the _id of another page. 949 | * `position` must be `before`, `after` or `inside`. The page whose `_id` appears in the URL is moved `before`, `after` or `inside` the page specified by `targetId`. If `inside` is specified, the page becomes the first child of `targetId`. 950 | 951 | The home page and other "parked" pages may not be moved. 952 | 953 | ## Rendering full pages and page fragments 954 | 955 | Ordinarily, the API simply returns the content of the page or piece as a JSON data structure. Sometimes, you'd like rendered markup. 956 | 957 | ### Rendering a full page experience 958 | 959 | If you just want the full page representation of a page or piece, rendered as Apostrophe would normally do it, use the API to fetch information about that page or piece, and then separately request the URL in its `._url` property. 960 | 961 | > If you make that request from a browser, it will be detected as an AJAX (“xhr”) request, and the outermost markup of the page (styles, script tags, etc.) will not be returned, just the portion inside the div with the `apos-refreshable` class. You can also get this effect in a non-browser request by setting the `apos_refresh=1` query parameter. Otherwise the page is fully rendered, including assets. 962 | 963 | ### Rendering a page or piece as an HTML fragment 964 | 965 | If you wish to render just a fragment of HTML, read on to see how you can create your own templates specifically for use with the API. This is the best approach when Apostrophe content is just one part of the page or experience you are building. 966 | 967 | Let's return to the "products" example and create a Nunjucks template to be rendered by the API: 968 | 969 | ```markup 970 | {# In lib/modules/products/views/api/fragment.html #} 971 | 972 | {# Let's output the title of the piece #} 973 |

{{ data.piece.title }}

974 | {# Now let's render an area as Apostrophe normally would #} 975 | {{ apos.area(data.piece, 'body') }} 976 | {# On second thought, let's just render the first image in that area directly #} 977 | {% set image = apos.images.first(data.piece, 'body') %} 978 | {% if image %} 979 | 980 | {% endif %} 981 | ``` 982 | 983 | Now let's configure the `products` module to allow rendering of the `api/fragment.html` template: 984 | 985 | ```javascript 986 | // in app.js, building on your configuration of products earlier 987 | 'products': { 988 | extend: 'apostrophe-pieces', 989 | name: 'product', 990 | // etc... 991 | restApi: true, 992 | apiTemplates: [ 'fragment' ] 993 | } 994 | ``` 995 | 996 | You will now receive this fragment of HTML as part of the `render` property of a product retrieved from the API, as long as you ask for it as part of your `GET` REST API request: 997 | 998 | `/api/v1/products/ID-OF-PRODUCT-GOES-HERE?render=fragment` 999 | 1000 | Notice we have added `render=fragment` to the query string, to specifically ask that `api/fragment.html` be rendered. 1001 | 1002 | Now the response will look like: 1003 | 1004 | ```javascript 1005 | { 1006 | _id: "ID-OF-PRODUCT-GOES-HERE", 1007 | title: "Cool Product", 1008 | rendered: { 1009 | fragment: "

Cool Product

... more markup ..." 1010 | } 1011 | } 1012 | ``` 1013 | 1014 | > You can render more than one, by passing more than one value for `render`. The resulting URL will look like this: `?render[]=fragment&render[]=other` 1015 | > 1016 | > If you're using `qs` or another good query string builder, you won't have to worry about building that yourself. Just pass an array of template names as `render`. 1017 | 1018 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var request = require('request'); 3 | var cuid = require('cuid'); 4 | var _ = require('lodash'); 5 | var async = require('async'); 6 | var fs = require('fs-extra'); 7 | var path = require('path'); 8 | 9 | describe('test apostrophe-headless', function() { 10 | 11 | var apos; 12 | var adminGroup; 13 | var bearer; 14 | var productId; 15 | 16 | this.timeout(20000); 17 | 18 | after(function(done) { 19 | require('apostrophe/test-lib/util').destroy(apos, done); 20 | }); 21 | 22 | it('initializes', function(done) { 23 | apos = require('apostrophe')({ 24 | testModule: true, 25 | shortName: 'apostrophe-headless-test', 26 | modules: { 27 | 'apostrophe-express': { 28 | secret: 'xxx', 29 | port: 7900, 30 | csrf: { 31 | exceptions: '/excepted-post-route' 32 | } 33 | }, 34 | 'apostrophe-headless': { 35 | bearerTokens: true, 36 | apiKeys: [ 'skeleton-key' ] 37 | }, 38 | products: { 39 | extend: 'apostrophe-pieces', 40 | restApi: { 41 | safeDistinct: [ '_articles' ] 42 | }, 43 | name: 'product', 44 | apiKeys: [ 'product-key' ], 45 | apiTemplates: [ 'fragment' ], 46 | addFields: [ 47 | { 48 | name: 'body', 49 | type: 'area', 50 | options: { 51 | widgets: { 52 | 'apostrophe-rich-text': {}, 53 | 'apostrophe-images': {} 54 | } 55 | } 56 | }, 57 | { 58 | name: 'color', 59 | type: 'select', 60 | choices: [ 61 | { 62 | label: 'Red', 63 | value: 'red' 64 | }, 65 | { 66 | label: 'Blue', 67 | value: 'blue' 68 | } 69 | ] 70 | }, 71 | { 72 | name: 'photo', 73 | type: 'attachment', 74 | group: 'images' 75 | }, 76 | { 77 | name: 'addresses', 78 | type: 'array', 79 | schema: [ 80 | { 81 | name: 'street', 82 | type: 'string' 83 | } 84 | ] 85 | }, 86 | { 87 | name: '_articles', 88 | type: 'joinByArray', 89 | filters: { 90 | projection: { 91 | title: 1, 92 | slug: 1 93 | } 94 | }, 95 | api: true 96 | } 97 | ] 98 | }, 99 | articles: { 100 | extend: 'apostrophe-pieces', 101 | restApi: true, 102 | name: 'article', 103 | addFields: [ 104 | { 105 | name: 'name', 106 | type: 'string' 107 | } 108 | ] 109 | }, 110 | 'apostrophe-images': { 111 | restApi: true 112 | }, 113 | 'apostrophe-users': { 114 | groups: [ 115 | { 116 | title: 'admin', 117 | permissions: [ 'admin' ] 118 | } 119 | ] 120 | }, 121 | 'apostrophe-pages': { 122 | restApi: true, 123 | apiKeys: [ 'page-key' ], 124 | apiTemplates: [ 'fragment' ], 125 | park: [ 126 | { 127 | type: 'default', 128 | title: 'Tab One', 129 | slug: '/tab-one', 130 | published: true, 131 | _children: [ 132 | { 133 | type: 'default', 134 | title: 'Tab One Child One', 135 | slug: '/tab-one/child-one', 136 | published: true 137 | }, 138 | { 139 | type: 'default', 140 | title: 'Tab One Child Two', 141 | slug: '/tab-one/child-two', 142 | published: true 143 | } 144 | ] 145 | }, 146 | { 147 | type: 'default', 148 | title: 'Tab Two', 149 | slug: '/tab-two', 150 | published: true, 151 | _children: [ 152 | { 153 | type: 'default', 154 | title: 'Tab Two Child One', 155 | slug: '/tab-two/child-one', 156 | published: true 157 | }, 158 | { 159 | type: 'default', 160 | title: 'Tab Two Child Two', 161 | slug: '/tab-two/child-two', 162 | published: true 163 | } 164 | ], 165 | body: { 166 | type: 'area', 167 | items: [ 168 | { 169 | type: 'apostrophe-rich-text', 170 | content: '

How I discovered cheese

\n' + 171 | '

In the mountains of Pennsport, I found a spring.

' 172 | } 173 | ] 174 | } 175 | } 176 | ] 177 | }, 178 | 'default-pages': { 179 | extend: 'apostrophe-custom-pages', 180 | name: 'default', 181 | addFields: [ 182 | { 183 | name: 'addresses', 184 | type: 'array', 185 | schema: [ 186 | { 187 | name: 'street', 188 | type: 'string' 189 | } 190 | ] 191 | } 192 | ] 193 | } 194 | }, 195 | afterInit: function(callback) { 196 | // Should NOT have an alias! 197 | assert(!apos.restApi); 198 | assert(apos.modules.products); 199 | assert(apos.modules.products.addRestApiRoutes); 200 | return callback(null); 201 | }, 202 | afterListen: function(err) { 203 | assert(!err); 204 | done(); 205 | } 206 | }); 207 | }); 208 | 209 | it('can locate the admin group', function(done) { 210 | return apos.docs.db.findOne({ 211 | title: 'admin', 212 | type: 'apostrophe-group' 213 | }, function(err, group) { 214 | assert(!err); 215 | assert(group); 216 | adminGroup = group; 217 | done(); 218 | }); 219 | }); 220 | 221 | it('can insert a test user via apostrophe-users', function(done) { 222 | var user = apos.users.newInstance(); 223 | 224 | user.firstName = 'test'; 225 | user.lastName = 'test'; 226 | user.title = 'test test'; 227 | user.username = 'test'; 228 | user.password = 'test'; 229 | user.email = 'test@test.com'; 230 | user.groupIds = [ adminGroup._id ]; 231 | 232 | assert(user.type === 'apostrophe-user'); 233 | assert(apos.users.insert); 234 | apos.users.insert(apos.tasks.getReq(), user, function(err) { 235 | if (err) { 236 | console.error(err); 237 | } 238 | assert(!err); 239 | done(); 240 | }); 241 | 242 | }); 243 | 244 | it('can log in via REST as that user, obtain bearer token', function(done) { 245 | http('/api/v1/login', 'POST', {}, { 246 | username: 'test', 247 | password: 'test' 248 | }, undefined, function(err, result) { 249 | assert(!err); 250 | assert(result && result.bearer); 251 | bearer = result.bearer; 252 | done(); 253 | }); 254 | }); 255 | 256 | it('cannot POST a product without a bearer token', function(done) { 257 | http('/api/v1/products', 'POST', {}, { 258 | title: 'Fake Product', 259 | body: { 260 | type: 'area', 261 | items: [ 262 | { 263 | type: 'apostrophe-rich-text', 264 | id: cuid(), 265 | content: '

This is fake

' 266 | } 267 | ] 268 | } 269 | }, undefined, function(err, response) { 270 | assert(err); 271 | done(); 272 | }); 273 | }); 274 | 275 | var updateProduct; 276 | 277 | it('can POST products with a bearer token, some published', function(done) { 278 | // range is exclusive at the top end, I want 10 things 279 | var nths = _.range(1, 11); 280 | return async.eachSeries(nths, function(i, callback) { 281 | http('/api/v1/products', 'POST', {}, { 282 | title: 'Cool Product #' + i, 283 | published: !!(i & 1), 284 | body: { 285 | type: 'area', 286 | items: [ 287 | { 288 | type: 'apostrophe-rich-text', 289 | id: cuid(), 290 | content: '

This is thing ' + i + '

' 291 | } 292 | ] 293 | } 294 | }, bearer, function(err, response) { 295 | assert(!err); 296 | assert(response); 297 | assert(response._id); 298 | assert(response.title === 'Cool Product #' + i); 299 | assert(response.slug === 'cool-product-' + i); 300 | assert(response.type === 'product'); 301 | if (i === 1) { 302 | updateProduct = response; 303 | } 304 | return callback(null); 305 | }); 306 | }, function(err) { 307 | assert(!err); 308 | done(); 309 | }); 310 | }); 311 | 312 | it('can GET five of those products without a bearer token', function(done) { 313 | return http('/api/v1/products', 'GET', {}, {}, undefined, function(err, response) { 314 | assert(!err); 315 | assert(response); 316 | assert(response.results); 317 | assert(response.results.length === 5); 318 | done(); 319 | }); 320 | }); 321 | 322 | it('Request with an invalid bearer token is a 401, even if it would otherwise be publicly accessible', function(done) { 323 | return http('/api/v1/products', 'GET', {}, {}, 'madeupbearertoken', function(err, response) { 324 | assert(err); 325 | assert(err.status === 401); 326 | assert(err.body.error); 327 | assert(err.body.error === 'bearer token invalid'); 328 | done(); 329 | }); 330 | }); 331 | 332 | it('can GET five of those products with a bearer token and no query parameters', function(done) { 333 | return http('/api/v1/products', 'GET', {}, {}, bearer, function(err, response) { 334 | assert(!err); 335 | assert(response); 336 | assert(response.results); 337 | assert(response.results.length === 5); 338 | done(); 339 | }); 340 | }); 341 | 342 | it('can GET all ten of those products with a bearer token and published: "any"', function(done) { 343 | return http('/api/v1/products', 'GET', { published: 'any' }, {}, bearer, function(err, response) { 344 | assert(!err); 345 | assert(response); 346 | assert(response.results); 347 | assert(response.results.length === 10); 348 | done(); 349 | }); 350 | }); 351 | 352 | var firstId; 353 | 354 | it('can GET only 5 if perPage is 5', function(done) { 355 | http('/api/v1/products', 'GET', { 356 | perPage: 5, 357 | published: 'any' 358 | }, {}, bearer, function(err, response) { 359 | assert(!err); 360 | assert(response); 361 | assert(response.results); 362 | assert(response.results.length === 5); 363 | firstId = response.results[0]._id; 364 | assert(response.pages === 2); 365 | done(); 366 | }); 367 | }); 368 | 369 | it('can GET a different 5 on page 2', function(done) { 370 | http('/api/v1/products', 'GET', { 371 | perPage: 5, 372 | published: 'any', 373 | page: 2 374 | }, {}, bearer, function(err, response) { 375 | assert(!err); 376 | assert(response); 377 | assert(response.results); 378 | assert(response.results.length === 5); 379 | assert(response.results[0]._id !== firstId); 380 | assert(response.pages === 2); 381 | done(); 382 | }); 383 | }); 384 | 385 | it('can update a product', function(done) { 386 | http('/api/v1/products/' + updateProduct._id, 'PUT', {}, _.assign( 387 | {}, 388 | updateProduct, 389 | { 390 | title: 'I like cheese', 391 | _id: 'should-not-change' 392 | } 393 | ), bearer, function(err, response) { 394 | assert(!err); 395 | assert(response); 396 | assert(response._id === updateProduct._id); 397 | assert(response.title === 'I like cheese'); 398 | assert(response.body.items.length); 399 | done(); 400 | }); 401 | }); 402 | 403 | it('fetch of updated product shows updated content', function(done) { 404 | http('/api/v1/products/' + updateProduct._id, 'GET', {}, {}, bearer, function(err, response) { 405 | assert(!err); 406 | assert(response); 407 | assert(response.title === 'I like cheese'); 408 | assert(response.body.items.length); 409 | done(); 410 | }); 411 | }); 412 | 413 | it('can delete a product', function(done) { 414 | http('/api/v1/products/' + updateProduct._id, 'DELETE', {}, {}, bearer, function(err, response) { 415 | assert(!err); 416 | done(); 417 | }); 418 | }); 419 | 420 | it('cannot fetch a deleted product', function(done) { 421 | http('/api/v1/products/' + updateProduct._id, 'GET', {}, {}, bearer, function(err, response) { 422 | assert(err); 423 | done(); 424 | }); 425 | }); 426 | 427 | it('can insert a product with the skeleton api key, via query string', function(done) { 428 | http('/api/v1/products', 'POST', { apiKey: 'skeleton-key' }, { 429 | title: 'Skeleton Key Product', 430 | body: { 431 | type: 'area', 432 | items: [ 433 | { 434 | type: 'apostrophe-rich-text', 435 | id: cuid(), 436 | content: '

This is the skeleton key product

' 437 | } 438 | ] 439 | } 440 | }, undefined, function(err, response) { 441 | assert(!err); 442 | done(); 443 | }); 444 | }); 445 | 446 | it('can insert a product with the products-only api key, via query string', function(done) { 447 | http('/api/v1/products', 'POST', { apiKey: 'product-key' }, { 448 | title: 'Product Key Product', 449 | body: { 450 | type: 'area', 451 | items: [ 452 | { 453 | type: 'apostrophe-rich-text', 454 | id: cuid(), 455 | content: '

This is the product key product

' 456 | } 457 | ] 458 | } 459 | }, undefined, function(err, response) { 460 | assert(!err); 461 | productId = response._id; 462 | assert(productId); 463 | done(); 464 | }); 465 | }); 466 | 467 | it('can insert a product with the skeleton api key, via auth header', function(done) { 468 | http('/api/v1/products', 'POST', { apiKey: 'product-key' }, { 469 | title: 'Product Key Product', 470 | body: { 471 | type: 'area', 472 | items: [ 473 | { 474 | type: 'apostrophe-rich-text', 475 | id: cuid(), 476 | content: '

This is the product key product

' 477 | } 478 | ] 479 | } 480 | }, undefined, { 481 | headers: { 482 | Authorization: 'Api-Key skeleton-key' 483 | } 484 | }, function(err, response) { 485 | assert(!err); 486 | done(); 487 | }); 488 | }); 489 | 490 | var joinedProductId; 491 | 492 | it('can insert a product with joins', function(done) { 493 | http('/api/v1/articles', 'POST', { apiKey: 'skeleton-key' }, { 494 | title: 'First Article', 495 | name: 'first-article' 496 | }, undefined, function(err, response) { 497 | assert(!err); 498 | var articleId = response._id; 499 | assert(articleId); 500 | 501 | http('/api/v1/products', 'POST', { apiKey: 'skeleton-key' }, { 502 | title: 'Product Key Product With Join', 503 | body: { 504 | type: 'area', 505 | items: [ 506 | { 507 | type: 'apostrophe-rich-text', 508 | id: cuid(), 509 | content: '

This is the product key product with join

' 510 | } 511 | ] 512 | }, 513 | articlesIds: [ articleId ] 514 | }, undefined, function(err, response) { 515 | assert(!err); 516 | assert(response._id); 517 | assert(response.articlesIds[0] === articleId); 518 | joinedProductId = response._id; 519 | done(); 520 | }); 521 | }); 522 | }); 523 | 524 | it('cannot insert a product with a bad api key, via query string', function(done) { 525 | http('/api/v1/products', 'POST', { apiKey: 'woo-woo' }, { 526 | title: 'Bogus Product', 527 | body: { 528 | type: 'area', 529 | items: [ 530 | { 531 | type: 'apostrophe-rich-text', 532 | id: cuid(), 533 | content: '

This is the bogus product

' 534 | } 535 | ] 536 | } 537 | }, undefined, function(err, response) { 538 | assert(err); 539 | done(); 540 | }); 541 | }); 542 | 543 | var attachment; 544 | var productWithPhoto; 545 | 546 | it('can post an attachment with a bearer token', function(done) { 547 | return request({ 548 | url: 'http://localhost:7900/api/v1/attachments', 549 | method: 'POST', 550 | formData: { 551 | file: fs.createReadStream(path.join(__dirname, '/test-image.jpg')) 552 | }, 553 | json: true, 554 | auth: { bearer: bearer } 555 | }, function(err, response, body) { 556 | assert(!err); 557 | assert(response.statusCode < 400); 558 | assert(typeof (body) === 'object'); 559 | assert(body._id); 560 | attachment = body; 561 | done(); 562 | }); 563 | }); 564 | 565 | it('can post an attachment with the skeleton API key (query string)', function(done) { 566 | return request({ 567 | url: 'http://localhost:7900/api/v1/attachments?apikey=skeleton-key', 568 | method: 'POST', 569 | formData: { 570 | file: fs.createReadStream(path.join(__dirname, '/test-image.jpg')) 571 | }, 572 | json: true 573 | }, function(err, response, body) { 574 | assert(!err); 575 | assert(response.statusCode < 400); 576 | assert(typeof (body) === 'object'); 577 | assert(body._id); 578 | done(); 579 | }); 580 | }); 581 | 582 | it('can post an attachment with the product API key (query string)', function(done) { 583 | return request({ 584 | url: 'http://localhost:7900/api/v1/attachments?apikey=product-key', 585 | method: 'POST', 586 | formData: { 587 | file: fs.createReadStream(path.join(__dirname, '/test-image.jpg')) 588 | }, 589 | json: true 590 | }, function(err, response, body) { 591 | assert(!err); 592 | assert(response.statusCode < 400); 593 | assert(typeof (body) === 'object'); 594 | assert(body._id); 595 | done(); 596 | }); 597 | }); 598 | 599 | it('can post an attachment with the skeleton API key (via auth header)', function(done) { 600 | return request({ 601 | url: 'http://localhost:7900/api/v1/attachments', 602 | method: 'POST', 603 | formData: { 604 | file: fs.createReadStream(path.join(__dirname, '/test-image.jpg')) 605 | }, 606 | json: true, 607 | headers: { 608 | Authorization: 'ApiKey skeleton-key' 609 | } 610 | }, function(err, response, body) { 611 | assert(!err); 612 | assert(response.statusCode < 400); 613 | assert(typeof (body) === 'object'); 614 | assert(body._id); 615 | done(); 616 | }); 617 | }); 618 | 619 | it('cannot post an attachment without any api key', function(done) { 620 | return request({ 621 | url: 'http://localhost:7900/api/v1/attachments', 622 | method: 'POST', 623 | formData: { 624 | file: fs.createReadStream(path.join(__dirname, '/test-image.jpg')) 625 | }, 626 | json: true 627 | }, function(err, response, body) { 628 | assert(!err); 629 | assert(response.statusCode >= 400); 630 | done(); 631 | }); 632 | }); 633 | 634 | it('can upload a product containing an attachment', function(done) { 635 | http('/api/v1/products', 'POST', {}, { 636 | title: 'Product With Photo', 637 | body: { 638 | type: 'area', 639 | items: [ 640 | { 641 | type: 'apostrophe-rich-text', 642 | id: cuid(), 643 | content: '

Has a Photo

' 644 | } 645 | ] 646 | }, 647 | photo: attachment 648 | }, bearer, function(err, response) { 649 | assert(!err); 650 | assert(response); 651 | productWithPhoto = response; 652 | done(); 653 | }); 654 | }); 655 | 656 | it('can GET a product containing an attachment and it has image URLs', function(done) { 657 | http('/api/v1/products/' + productWithPhoto._id, 'GET', {}, undefined, undefined, function(err, response) { 658 | assert(!err); 659 | assert(response); 660 | assert(response._id === productWithPhoto._id); 661 | assert(response.photo); 662 | assert(response.photo._id === attachment._id); 663 | assert(response.photo._urls); 664 | assert(response.photo._urls.original); 665 | assert(response.photo._urls.full); 666 | done(); 667 | }); 668 | }); 669 | 670 | it('can patch product without affecting title', function(done) { 671 | return http('/api/v1/products/' + productId, 'PATCH', {}, { 672 | addresses: [ 673 | { 674 | street: '101 Test Lane' 675 | }, 676 | { 677 | street: '102 Test Lane' 678 | } 679 | ] 680 | }, bearer, function(err, response) { 681 | assert(!err); 682 | assert(response.title === 'Product Key Product'); 683 | assert(response.addresses); 684 | assert(response.addresses[0]); 685 | assert(response.addresses[0].street === '101 Test Lane'); 686 | assert(response.addresses[1]); 687 | assert(response.addresses[1].street === '102 Test Lane'); 688 | assert(!response.addresses[2]); 689 | done(); 690 | }); 691 | }); 692 | 693 | it('can append to addresses array', function(done) { 694 | return http('/api/v1/products/' + productId, 'PATCH', {}, { 695 | $push: { 696 | addresses: { 697 | street: '103 Test Lane' 698 | } 699 | } 700 | }, bearer, function(err, response) { 701 | assert(!err); 702 | assert(response.title === 'Product Key Product'); 703 | assert(response.addresses); 704 | assert(response.addresses[0]); 705 | assert(response.addresses[0].street === '101 Test Lane'); 706 | assert(response.addresses[1]); 707 | assert(response.addresses[1].street === '102 Test Lane'); 708 | assert(response.addresses[2]); 709 | assert(response.addresses[2].street === '103 Test Lane'); 710 | assert(!response.addresses[3]); 711 | done(); 712 | }); 713 | }); 714 | 715 | it('can append many entries to addresses array', function(done) { 716 | return http('/api/v1/products/' + productId, 'PATCH', {}, { 717 | $push: { 718 | addresses: { 719 | $each: [ 720 | { 721 | street: '104 Test Lane' 722 | }, 723 | { 724 | street: '105 Test Lane' 725 | }, 726 | { 727 | street: '106 Test Lane' 728 | } 729 | ] 730 | } 731 | } 732 | }, bearer, function(err, response) { 733 | assert(!err); 734 | assert(response.title === 'Product Key Product'); 735 | assert(response.addresses.length === 6); 736 | assert(_.find(response.addresses, { street: '104 Test Lane' })); 737 | assert(_.find(response.addresses, { street: '105 Test Lane' })); 738 | assert(_.find(response.addresses, { street: '106 Test Lane' })); 739 | addresses = response.addresses; 740 | done(); 741 | }); 742 | }); 743 | 744 | it('can remove entries from the set by value', function(done) { 745 | return http('/api/v1/products/' + productId, 'PATCH', {}, { 746 | $pullAll: { 747 | addresses: [ addresses[0] ] 748 | } 749 | }, bearer, function(err, response) { 750 | assert(!err); 751 | assert(response.title === 'Product Key Product'); 752 | assert(response.addresses.length === 5); 753 | assert(!_.find(response.addresses, { street: '101 Test Lane' })); 754 | assert(_.find(response.addresses, { street: '102 Test Lane' })); 755 | addresses = response.addresses; 756 | done(); 757 | }); 758 | }); 759 | 760 | it('can remove entries from the set by id', function(done) { 761 | return http('/api/v1/products/' + productId, 'PATCH', {}, { 762 | $pullAllById: { 763 | addresses: [ addresses[0].id ] 764 | } 765 | }, bearer, function(err, response) { 766 | assert(!err); 767 | assert(response.title === 'Product Key Product'); 768 | assert(response.addresses.length === 4); 769 | assert(!_.find(response.addresses, { street: '102 Test Lane' })); 770 | assert(_.find(response.addresses, { street: '103 Test Lane' })); 771 | done(); 772 | }); 773 | }); 774 | 775 | it('can render a fragment of a product', function(done) { 776 | http('/api/v1/products/' + productWithPhoto._id, 'GET', { render: 'fragment' }, undefined, undefined, function(err, result) { 777 | assert(!err); 778 | assert(result); 779 | assert(result.rendered); 780 | assert(result.rendered.fragment); 781 | assert(result.rendered.fragment.indexOf('

Product With Photo

') !== -1); 782 | done(); 783 | }); 784 | }); 785 | 786 | it('can render a fragment of many products', function(done) { 787 | http('/api/v1/products', 'GET', { render: 'fragment' }, undefined, undefined, function(err, result) { 788 | assert(!err); 789 | assert(result); 790 | assert(result.results.length >= 2); 791 | assert(result.results[0].rendered.fragment.indexOf('

Product Key Product

') !== -1); 792 | assert(result.results[1].rendered.fragment.indexOf('

Product With Photo

') !== -1); 793 | done(); 794 | }); 795 | }); 796 | 797 | var tabOneId, tabTwoId, addresses; 798 | 799 | it('unpark the parked pages other than home and trash to allow testing of move function', function(done) { 800 | apos.docs.db.update({ 801 | $and: [ 802 | { slug: /^\// }, 803 | { 804 | slug: { 805 | $nin: [ '/', '/trash' ] 806 | } 807 | } 808 | ] 809 | }, { 810 | $unset: { 811 | parked: 1 812 | } 813 | }, 814 | function(err) { 815 | assert(!err); 816 | done(); 817 | }); 818 | }); 819 | 820 | it('can get the home page and its children', function(done) { 821 | return http('/api/v1/apostrophe-pages', 'GET', {}, {}, undefined, function(err, response) { 822 | assert(!err); 823 | assert(response); 824 | assert(response.slug === '/'); 825 | assert(response._children); 826 | assert(response._children.length === 2); 827 | assert(response._children[0].title === 'Tab One'); 828 | assert(response._children[1].title === 'Tab Two'); 829 | assert(!(response._children[0]._children && response._children[0]._children.length)); 830 | tabOneId = response._children[0]._id; 831 | tabTwoId = response._children[1]._id; 832 | assert(tabOneId); 833 | done(); 834 | }); 835 | }); 836 | 837 | it('can exclude children from the response with a parameter', function(done) { 838 | return http('/api/v1/apostrophe-pages?children=false', 'GET', {}, {}, undefined, function(err, response) { 839 | assert(!err); 840 | assert(response); 841 | assert(response.slug === '/'); 842 | assert(!response._children); 843 | assert(response._ancestors); 844 | done(); 845 | }); 846 | }); 847 | 848 | it('can get an individual page by id, with its children', function(done) { 849 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'GET', {}, {}, undefined, function(err, response) { 850 | assert(!err); 851 | assert(response); 852 | assert(response.slug === '/tab-one'); 853 | assert(response._children); 854 | assert(response._children.length === 2); 855 | assert(response._children[0].title === 'Tab One Child One'); 856 | assert(response._children[1].title === 'Tab One Child Two'); 857 | assert(!(response._children[0]._children && response._children[0]._children.length)); 858 | done(); 859 | }); 860 | }); 861 | 862 | it('can exclude ancestors from the response with a parameter', function(done) { 863 | return http('/api/v1/apostrophe-pages/' + tabOneId + '?ancestors=false', 'GET', {}, {}, undefined, function(err, response) { 864 | assert(!err); 865 | assert(response); 866 | assert(response.slug === '/tab-one'); 867 | assert(response._children); 868 | assert(!response._ancestors); 869 | done(); 870 | }); 871 | }); 872 | 873 | it('cannot get the entire page tree without an api key', function(done) { 874 | return http('/api/v1/apostrophe-pages', 'GET', { all: 1 }, {}, undefined, function(err, response) { 875 | assert(err); 876 | done(); 877 | }); 878 | }); 879 | 880 | it('can get the entire page tree with an api key', function(done) { 881 | return http('/api/v1/apostrophe-pages', 'GET', { 882 | all: 1, 883 | apiKey: 'page-key' 884 | }, {}, undefined, function(err, response) { 885 | assert(!err); 886 | assert(response); 887 | assert(response.slug === '/'); 888 | assert(response._children); 889 | assert(response._children.length === 2); 890 | assert(response._children[0].title === 'Tab One'); 891 | assert(response._children[1].title === 'Tab Two'); 892 | assert(response._children[0]._children); 893 | assert(response._children[0]._children.length === 2); 894 | done(); 895 | }); 896 | }); 897 | 898 | it('can get the entire page tree as a flat array with an api key', function(done) { 899 | return http('/api/v1/apostrophe-pages', 'GET', { 900 | all: 1, 901 | flat: 1, 902 | apiKey: 'page-key' 903 | }, {}, undefined, function(err, response) { 904 | assert(!err); 905 | assert(response); 906 | assert(response.length); 907 | assert(response[0].slug === '/'); 908 | assert(response[1].slug === '/tab-one'); 909 | assert(response[2].slug === '/tab-one/child-one'); 910 | assert(response[0]._children[0] === response[1]._id); 911 | done(); 912 | }); 913 | }); 914 | 915 | var newPage; 916 | 917 | it('can insert a new grandchild page with the pages key', function(done) { 918 | http('/api/v1/apostrophe-pages', 'POST', { apiKey: 'page-key' }, { 919 | _parentId: tabOneId, 920 | title: 'Tab One Child Three', 921 | type: 'default', 922 | body: { 923 | type: 'area', 924 | items: [ 925 | { 926 | type: 'apostrophe-rich-text', 927 | id: cuid(), 928 | content: '

This is tab one child three

' 929 | } 930 | ] 931 | } 932 | }, undefined, function(err, response) { 933 | assert(!err); 934 | assert(response.level === 2); 935 | assert(response.path === '/tab-one/tab-one-child-three'); 936 | newPage = response; 937 | done(); 938 | }); 939 | }); 940 | 941 | it('can update grandchild page with the pages key', function(done) { 942 | newPage.title = 'Tab One Child Three Modified'; 943 | http('/api/v1/apostrophe-pages/' + newPage._id, 'PUT', { apiKey: 'page-key' }, newPage, undefined, function(err, response) { 944 | assert(!err); 945 | assert(response.title === 'Tab One Child Three Modified'); 946 | assert(response.published); 947 | done(); 948 | }); 949 | }); 950 | 951 | it('can "delete" grandchild page', function(done) { 952 | http('/api/v1/apostrophe-pages/' + newPage._id, 'DELETE', { apiKey: 'page-key' }, {}, undefined, function(err, response) { 953 | assert(!err); 954 | done(); 955 | }); 956 | }); 957 | 958 | it('can turn a child into a grandchild', function(done) { 959 | http('/api/v1/apostrophe-pages/' + tabOneId + '/move', 'POST', { apiKey: 'page-key' }, { 960 | targetId: tabTwoId, 961 | position: 'inside' 962 | }, undefined, function(err, response) { 963 | assert(!err); 964 | done(); 965 | }); 966 | }); 967 | 968 | it('page tree reflects move of child to be grandchild', function(done) { 969 | return http('/api/v1/apostrophe-pages', 'GET', { 970 | all: 1, 971 | apiKey: 'page-key' 972 | }, {}, undefined, function(err, response) { 973 | assert(!err); 974 | assert(response); 975 | assert(response.slug === '/'); 976 | assert(response._children); 977 | assert(response._children.length === 1); 978 | assert(response._children[0].title === 'Tab Two'); 979 | assert(response._children[0]._children && (response._children[0]._children.length === 3)); 980 | assert(response._children[0]._children[0].title === 'Tab One'); 981 | done(); 982 | }); 983 | }); 984 | 985 | it('can patch grandchild page without affecting title', function(done) { 986 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'PATCH', { apiKey: 'page-key' }, { 987 | addresses: [ 988 | { 989 | street: '101 Test Lane' 990 | }, 991 | { 992 | street: '102 Test Lane' 993 | } 994 | ] 995 | }, undefined, function(err, response) { 996 | assert(!err); 997 | assert(response.title === 'Tab One'); 998 | assert(response.addresses); 999 | assert(response.addresses[0]); 1000 | assert(response.addresses[0].street === '101 Test Lane'); 1001 | assert(response.addresses[1]); 1002 | assert(response.addresses[1].street === '102 Test Lane'); 1003 | assert(!response.addresses[2]); 1004 | done(); 1005 | }); 1006 | }); 1007 | 1008 | it('can append to addresses array', function(done) { 1009 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'PATCH', { apiKey: 'page-key' }, { 1010 | $push: { 1011 | addresses: { 1012 | street: '103 Test Lane' 1013 | } 1014 | } 1015 | }, undefined, function(err, response) { 1016 | assert(!err); 1017 | assert(response.title === 'Tab One'); 1018 | assert(response.addresses); 1019 | assert(response.addresses[0]); 1020 | assert(response.addresses[0].street === '101 Test Lane'); 1021 | assert(response.addresses[1]); 1022 | assert(response.addresses[1].street === '102 Test Lane'); 1023 | assert(response.addresses[2]); 1024 | assert(response.addresses[2].street === '103 Test Lane'); 1025 | assert(!response.addresses[3]); 1026 | done(); 1027 | }); 1028 | }); 1029 | 1030 | it('can append many entries to addresses array', function(done) { 1031 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'PATCH', { apiKey: 'page-key' }, { 1032 | $push: { 1033 | addresses: { 1034 | $each: [ 1035 | { 1036 | street: '104 Test Lane' 1037 | }, 1038 | { 1039 | street: '105 Test Lane' 1040 | }, 1041 | { 1042 | street: '106 Test Lane' 1043 | } 1044 | ] 1045 | } 1046 | } 1047 | }, undefined, function(err, response) { 1048 | assert(!err); 1049 | assert(response.title === 'Tab One'); 1050 | assert(response.addresses.length === 6); 1051 | assert(_.find(response.addresses, { street: '104 Test Lane' })); 1052 | assert(_.find(response.addresses, { street: '105 Test Lane' })); 1053 | assert(_.find(response.addresses, { street: '106 Test Lane' })); 1054 | addresses = response.addresses; 1055 | done(); 1056 | }); 1057 | }); 1058 | 1059 | it('can remove entries from the set by value', function(done) { 1060 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'PATCH', { apiKey: 'page-key' }, { 1061 | $pullAll: { 1062 | addresses: [ addresses[0] ] 1063 | } 1064 | }, undefined, function(err, response) { 1065 | assert(!err); 1066 | assert(response.title === 'Tab One'); 1067 | assert(response.addresses.length === 5); 1068 | assert(!_.find(response.addresses, { street: '101 Test Lane' })); 1069 | assert(_.find(response.addresses, { street: '102 Test Lane' })); 1070 | addresses = response.addresses; 1071 | done(); 1072 | }); 1073 | }); 1074 | 1075 | it('can remove entries from the set by id', function(done) { 1076 | return http('/api/v1/apostrophe-pages/' + tabOneId, 'PATCH', { apiKey: 'page-key' }, { 1077 | $pullAllById: { 1078 | addresses: [ addresses[0].id ] 1079 | } 1080 | }, undefined, function(err, response) { 1081 | assert(!err); 1082 | assert(response.title === 'Tab One'); 1083 | assert(response.addresses.length === 4); 1084 | assert(!_.find(response.addresses, { street: '102 Test Lane' })); 1085 | assert(_.find(response.addresses, { street: '103 Test Lane' })); 1086 | done(); 1087 | }); 1088 | }); 1089 | 1090 | it('can render a page', function(done) { 1091 | http('/api/v1/apostrophe-pages/' + tabTwoId, 'GET', { 1092 | render: 'fragment', 1093 | apiKey: 'page-key' 1094 | }, { 1095 | targetId: tabTwoId, 1096 | position: 'inside' 1097 | }, undefined, function(err, response) { 1098 | assert(!err); 1099 | assert(response.rendered && response.rendered.fragment && response.rendered.fragment.indexOf('

Tab Two

') !== -1); 1100 | assert(response.rendered && response.rendered.fragment && response.rendered.fragment.indexOf('cheese') !== -1); 1101 | done(); 1102 | }); 1103 | }); 1104 | 1105 | it('cannot GET a product when user has not the right permission', function(done) { 1106 | var saveRestApi = apos.modules.products.options.restApi; 1107 | apos.modules.products.options.restApi = { 1108 | getRequiresEditPermission: true 1109 | }; 1110 | return http('/api/v1/products', 'GET', {}, {}, undefined, function(err, response) { 1111 | assert(!err); 1112 | assert(response); 1113 | assert(response.results); 1114 | assert(response.results.length === 0); 1115 | apos.modules.products.options.restApi = saveRestApi; 1116 | done(); 1117 | }); 1118 | }); 1119 | 1120 | it('can GET a product without private fields', function(done) { 1121 | apos.modules.products.schema[0].api = false; 1122 | var name = apos.modules.products.schema[0].name; 1123 | return http('/api/v1/products', 'GET', {}, {}, undefined, function(err, response) { 1124 | assert(!err); 1125 | assert(response); 1126 | assert(response.results); 1127 | assert(typeof response.results[0][name] === 'undefined'); 1128 | apos.modules.products.schema[0].api = true; 1129 | done(); 1130 | }); 1131 | }); 1132 | 1133 | it('can GET a product with only some fields and includeFields has the priority over excludeFields', function(done) { 1134 | apos.modules.products.schema[0].api = false; 1135 | var name = apos.modules.products.schema[0].name; 1136 | return http('/api/v1/products?includeFields=slug,type&excludeFields=slug,type', 'GET', {}, {}, undefined, function(err, response) { 1137 | assert(!err); 1138 | assert(response); 1139 | assert(response.results); 1140 | assert(typeof response.results[0].slug === 'string'); 1141 | assert(typeof response.results[0][name] === 'undefined'); 1142 | assert(typeof response.results[0].published === 'undefined'); 1143 | apos.modules.products.schema[0].api = true; 1144 | done(); 1145 | }); 1146 | }); 1147 | 1148 | it('can GET a product with only some fields but an excluded field from schema is always excluded', function(done) { 1149 | apos.modules.products.schema[0].api = false; 1150 | var name = apos.modules.products.schema[0].name; 1151 | return http('/api/v1/products?includeFields=slug,type,' + name, 'GET', {}, {}, undefined, function(err, response) { 1152 | assert(!err); 1153 | assert(response); 1154 | assert(response.results); 1155 | assert(typeof response.results[0].slug === 'string'); 1156 | assert(typeof response.results[0][name] === 'undefined'); 1157 | assert(typeof response.results[0].published === 'undefined'); 1158 | apos.modules.products.schema[0].api = true; 1159 | done(); 1160 | }); 1161 | }); 1162 | 1163 | it('can GET a product with only some fields excluded', function(done) { 1164 | apos.modules.products.schema[0].api = false; 1165 | var name = apos.modules.products.schema[0].name; 1166 | return http('/api/v1/products?excludeFields=slug,type', 'GET', {}, {}, undefined, function(err, response) { 1167 | assert(!err); 1168 | assert(response); 1169 | assert(response.results); 1170 | assert(typeof response.results[0].slug === 'undefined'); 1171 | assert(typeof response.results[0][name] === 'undefined'); 1172 | assert(typeof response.results[0].published === 'boolean'); 1173 | apos.modules.products.schema[0].api = true; 1174 | done(); 1175 | }); 1176 | }); 1177 | 1178 | it('can GET a product with only some fields excluded and an excluded field from schema is still excluded', function(done) { 1179 | apos.modules.products.schema[0].api = false; 1180 | var name = apos.modules.products.schema[0].name; 1181 | return http('/api/v1/products?excludeFields=slug,type,' + name, 'GET', {}, {}, undefined, function(err, response) { 1182 | assert(!err); 1183 | assert(response); 1184 | assert(response.results); 1185 | assert(typeof response.results[0].slug === 'undefined'); 1186 | assert(typeof response.results[0][name] === 'undefined'); 1187 | assert(typeof response.results[0].published === 'boolean'); 1188 | apos.modules.products.schema[0].api = true; 1189 | done(); 1190 | }); 1191 | }); 1192 | 1193 | it('can GET a product without excluded joins even if included in query', function(done) { 1194 | var articleInSchema = _.find(apos.modules.products.schema, { name: '_articles' }); 1195 | articleInSchema.api = 'editPermissionRequired'; 1196 | return http('/api/v1/products?includeFields=slug,type,_articles', 'GET', {}, {}, undefined, function(err, response) { 1197 | assert(!err); 1198 | assert(response); 1199 | assert(response.results); 1200 | var product = _.find(response.results, { slug: 'product-key-product-with-join' }); 1201 | assert(typeof product.type === 'string'); 1202 | assert(typeof product.slug === 'string'); 1203 | assert(typeof product._articles === 'undefined'); 1204 | done(); 1205 | }); 1206 | }); 1207 | 1208 | it('can GET a single product but not its joins when excluded', function(done) { 1209 | return http('/api/v1/products/' + joinedProductId, 'GET', {}, {}, undefined, function(err, response) { 1210 | assert(!err); 1211 | assert(response); 1212 | assert(response.slug === 'product-key-product-with-join'); 1213 | assert(!response._articles); 1214 | var articleInSchema = _.find(apos.modules.products.schema, { name: '_articles' }); 1215 | articleInSchema.api = true; 1216 | done(); 1217 | }); 1218 | }); 1219 | 1220 | it('can GET a product with joins', function(done) { 1221 | return http('/api/v1/products', 'GET', {}, {}, undefined, function(err, response) { 1222 | assert(!err); 1223 | assert(response); 1224 | assert(response.results); 1225 | var product = _.find(response.results, { slug: 'product-key-product-with-join' }); 1226 | assert(Array.isArray(product._articles)); 1227 | assert(product._articles.length === 1); 1228 | done(); 1229 | }); 1230 | }); 1231 | 1232 | it('can GET a single product with joins', function(done) { 1233 | return http('/api/v1/products/' + joinedProductId, 'GET', {}, {}, undefined, function(err, response) { 1234 | assert(!err); 1235 | assert(response); 1236 | assert(response._articles); 1237 | assert(response._articles.length === 1); 1238 | done(); 1239 | }); 1240 | }); 1241 | 1242 | it('cannot get an editPermissionRequired field without permissions', function(done) { 1243 | var articleInSchema = _.find(apos.modules.products.schema, { name: '_articles' }); 1244 | articleInSchema.api = 'editPermissionRequired'; 1245 | return http('/api/v1/products', 'GET', {}, {}, undefined, function(err, response) { 1246 | assert(!err); 1247 | assert(response); 1248 | assert(response.results); 1249 | var product = _.find(response.results, { slug: 'product-key-product-with-join' }); 1250 | assert(!product._articles); 1251 | // For the next test's normal operation 1252 | articleInSchema.api = true; 1253 | done(); 1254 | }); 1255 | }); 1256 | 1257 | it('can get an editPermissionRequired field with the bearer token', function(done) { 1258 | var articleInSchema = _.find(apos.modules.products.schema, { name: '_articles' }); 1259 | articleInSchema.api = 'editPermissionRequired'; 1260 | return http('/api/v1/products', 'GET', {}, {}, bearer, function(err, response) { 1261 | assert(!err); 1262 | assert(response); 1263 | assert(response.results); 1264 | var product = _.find(response.results, { slug: 'product-key-product-with-join' }); 1265 | assert(product._articles); 1266 | assert(product._articles.length === 1); 1267 | // For the next test's normal operation 1268 | articleInSchema.api = true; 1269 | done(); 1270 | }); 1271 | }); 1272 | 1273 | it('can GET a product with included joins', function(done) { 1274 | return http('/api/v1/products?includeFields=slug,type,_articles', 'GET', {}, {}, undefined, function(err, response) { 1275 | assert(!err); 1276 | assert(response); 1277 | assert(response.results); 1278 | var product = _.find(response.results, { slug: 'product-key-product-with-join' }); 1279 | assert(typeof product.type === 'string'); 1280 | assert(typeof product.slug === 'string'); 1281 | assert(Array.isArray(product._articles)); 1282 | assert(product._articles.length === 1); 1283 | done(); 1284 | }); 1285 | }); 1286 | 1287 | it('can GET results with distinct article join information', function(done) { 1288 | return http('/api/v1/products?distinct=_articles', 'GET', {}, {}, undefined, function(err, response) { 1289 | assert(!err); 1290 | assert(response); 1291 | assert(response.results); 1292 | assert(response.distinct); 1293 | assert(response.distinct._articles); 1294 | assert(response.distinct._articles[0].label === 'First Article'); 1295 | done(); 1296 | }); 1297 | }); 1298 | 1299 | it('can GET results with distinct article join count information', function(done) { 1300 | return http('/api/v1/products?distinct-counts=_articles', 'GET', {}, {}, undefined, function(err, response) { 1301 | assert(!err); 1302 | assert(response); 1303 | assert(response.results); 1304 | assert(response.distinct); 1305 | assert(response.distinct._articles); 1306 | assert(response.distinct._articles[0].label === 'First Article'); 1307 | assert(response.distinct._articles[0].count === 1); 1308 | done(); 1309 | }); 1310 | }); 1311 | 1312 | it('can patch a join', function(done) { 1313 | http('/api/v1/articles', 'POST', { apiKey: 'skeleton-key' }, { 1314 | title: 'Join Article', 1315 | name: 'join-article' 1316 | }, undefined, function(err, response) { 1317 | assert(!err); 1318 | var articleId = response._id; 1319 | assert(articleId); 1320 | 1321 | http('/api/v1/products', 'POST', { apiKey: 'skeleton-key' }, { 1322 | title: 'Initially No Join Value', 1323 | body: { 1324 | type: 'area', 1325 | items: [ 1326 | { 1327 | type: 'apostrophe-rich-text', 1328 | id: cuid(), 1329 | content: '

This is the product key product without initial join

' 1330 | } 1331 | ] 1332 | } 1333 | }, undefined, function(err, response) { 1334 | assert(!err); 1335 | var product = response; 1336 | assert(product._id); 1337 | http('/api/v1/products/' + product._id, 'PATCH', { apiKey: 'skeleton-key' }, { 1338 | articlesIds: [ articleId ] 1339 | }, undefined, function(err, response) { 1340 | assert(!err); 1341 | assert(response.title === 'Initially No Join Value'); 1342 | assert(response.articlesIds); 1343 | assert(response.articlesIds[0] === articleId); 1344 | done(); 1345 | }); 1346 | }); 1347 | }); 1348 | }); 1349 | 1350 | it('can log out to destroy a bearer token', function(done) { 1351 | http('/api/v1/logout', 'POST', {}, {}, bearer, function(err, result) { 1352 | assert(!err); 1353 | done(); 1354 | }); 1355 | }); 1356 | 1357 | it('cannot POST a product with a logged-out bearer token', function(done) { 1358 | http('/api/v1/products', 'POST', {}, { 1359 | title: 'Fake Product After Logout', 1360 | body: { 1361 | type: 'area', 1362 | items: [ 1363 | { 1364 | type: 'apostrophe-rich-text', 1365 | id: cuid(), 1366 | content: '

This is fake

' 1367 | } 1368 | ] 1369 | } 1370 | }, bearer, function(err, response) { 1371 | assert(err); 1372 | done(); 1373 | }); 1374 | }); 1375 | 1376 | it('can POST to a CSRF excepted custom route', function(done) { 1377 | http('/excepted-post-route', 'POST', {}, { 1378 | test: 'test' 1379 | }, undefined, function(err, response) { 1380 | assert(!err); 1381 | assert(response === 'ok'); 1382 | done(); 1383 | }); 1384 | }); 1385 | 1386 | it('cannot POST to a non-CSRF-excepted custom route', function(done) { 1387 | http('/non-excepted-post-route', 'POST', {}, { 1388 | test: 'test' 1389 | }, undefined, function(err, response) { 1390 | assert(err && (err.status === 403)); 1391 | done(); 1392 | }); 1393 | }); 1394 | 1395 | }); 1396 | 1397 | function http(url, method, query, form, bearer, extra, callback) { 1398 | if (arguments.length === 6) { 1399 | callback = extra; 1400 | extra = null; 1401 | } 1402 | var args = { 1403 | url: 'http://localhost:7900' + url, 1404 | qs: query || undefined, 1405 | form: ((method === 'POST') || (method === 'PUT') || (method === 'PATCH')) ? form : undefined, 1406 | method: method, 1407 | json: true, 1408 | auth: bearer ? { bearer: bearer } : undefined 1409 | }; 1410 | if (extra) { 1411 | _.assign(args, extra); 1412 | } 1413 | return request(args, function(err, response, body) { 1414 | if (err) { 1415 | return callback(err); 1416 | } 1417 | if (response.statusCode >= 400) { 1418 | return callback({ 1419 | status: response.statusCode, 1420 | body: body 1421 | }); 1422 | } 1423 | return callback(null, body); 1424 | }); 1425 | } 1426 | --------------------------------------------------------------------------------