├── 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 | [](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 |
--------------------------------------------------------------------------------