├── .DS_Store ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config └── test.js ├── docker-compose.yml ├── index.js ├── package-lock.json ├── package.json ├── src ├── actions.js ├── airtable.peg ├── audit.js ├── formula.js ├── functions.sql ├── restore.js └── sync.js └── test ├── 01-restore.js ├── 02-functional.js ├── 03-hooks.js ├── 04-invalid-input.js ├── 05-paging.js ├── rest.js └── utils.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshcodeIT/airtable-rest-graphql-postgres/b7568bfe1ceb2b35bc663c5e066ef8e24299d9af/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .vscode -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | WORKDIR /usr/app 4 | COPY package.json . 5 | 6 | RUN npm install --quiet 7 | 8 | COPY . . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Don't forget to set env variable for postgres database 3 | 4 | ```DATABASE_URL=postgres://airtable@postgres/airtable npm start``` 5 | 6 | or in docker-compose.yml 7 | 8 | ```javascript 9 | let airql = require('airtable-postgres-graphql'); 10 | let app = express(); 11 | 12 | const baseId = 'some base id'; 13 | const apiKey = 'some api key'; 14 | 15 | let {router, airtable} = airql.airtableRestRouter({apiKey: apiKey, base: baseId, tables: ['Property', 'Feature']}); 16 | airtable.setupPeriodicUpdate(); 17 | 18 | app.use(`/v0/${baseId}`, router); // for drop-in replacement usage with Airtable.js, just change host 19 | 20 | airtable.onChange((event, entity) => { 21 | switch(event){ 22 | case 'insert': 23 | return; 24 | case 'update': 25 | return; 26 | case 'delete': 27 | return; 28 | } 29 | }); 30 | 31 | airtable.onChange((type, event, old, new) => { 32 | if (event=='update' && old['Approve status']!=new['Approve status']) 33 | { 34 | checkApproval(JSON.parse(new['Approve status']), json); 35 | } 36 | }) 37 | 38 | airtable.onSelect((user, table, entity) => { 39 | // user select info about himself 40 | if (user.airtableId === entity.id) 41 | return entity; 42 | switch(table) { 43 | // don't return personal information, prefer Whitelist approach because field names in Airtable can be easily changed 44 | case 'Landlord': 45 | return _.pick(entity, ['Name', 'Type']); 46 | case 'Agent': 47 | return _.pick(entity, ['Name', 'MobilePhone']); 48 | } 49 | }); 50 | 51 | airtable.onAssignUser((req, res) => req.local.user); 52 | ``` 53 | 54 | # Benchmarks 55 | 56 | # Roadmap -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "apiKey": "keymYek7PsWGf6j7i", 3 | "base": "appi7MJY9TJIqNNJj", 4 | "tables": ["Property", "City name", "Feature"], 5 | "schema": "target" 6 | }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | test: 4 | build: . 5 | command: npm run test-in-docker 6 | depends_on: 7 | - postgres 8 | environment: 9 | DATABASE_URL: postgres://airtable@postgres/airtable 10 | NODE_ENV: test 11 | postgres: 12 | image: postgres:latest 13 | environment: 14 | POSTGRES_USER: airtable 15 | POSTGRES_DB: airtable -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | let AirtableRest = require('./src/actions'); 2 | let express = require('express'); 3 | 4 | function airtableRestRouter(config) { 5 | let router = express.Router({ mergeParams: true }); 6 | let airtable = new AirtableRest(config); 7 | router.route("/:table") 8 | .get(airtable.listRecords.bind(airtable)) 9 | .post(airtable.createRecord.bind(airtable)); 10 | router.route("/:table/:id") 11 | .get(airtable.retrieveRecord.bind(airtable)) 12 | .delete(airtable.deleteRecord.bind(airtable)) 13 | .patch(airtable.updateRecord.bind(airtable)); 14 | return {router, airtable}; 15 | } 16 | 17 | module.exports = { airtableRestRouter }; -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airtable-postgres-graphql", 3 | "version": "0.5.5", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@sinonjs/commons": { 8 | "version": "1.0.2", 9 | "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.0.2.tgz", 10 | "integrity": "sha512-WR3dlgqJP4QNrLC4iXN/5/2WaLQQ0VijOOkmflqFGVJ6wLEpbSjo7c0ZeGIdtY8Crk7xBBp87sM6+Mkerz7alw==", 11 | "requires": { 12 | "type-detect": "4.0.8" 13 | } 14 | }, 15 | "@sinonjs/formatio": { 16 | "version": "2.0.0", 17 | "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", 18 | "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", 19 | "requires": { 20 | "samsam": "1.3.0" 21 | } 22 | }, 23 | "@sinonjs/samsam": { 24 | "version": "2.0.0", 25 | "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.0.0.tgz", 26 | "integrity": "sha512-D7VxhADdZbDJ0HjUTMnSQ5xIGb4H2yWpg8k9Sf1T08zfFiQYlaxM8LZydpR4FQ2E6LZJX8IlabNZ5io4vdChwg==" 27 | }, 28 | "accepts": { 29 | "version": "1.3.5", 30 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", 31 | "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", 32 | "requires": { 33 | "mime-types": "~2.1.18", 34 | "negotiator": "0.6.1" 35 | } 36 | }, 37 | "airtable": { 38 | "version": "0.5.6", 39 | "resolved": "https://registry.npmjs.org/airtable/-/airtable-0.5.6.tgz", 40 | "integrity": "sha512-x3dH8IQ7EO6VLMl4saTMmZNha+qTuymUmPLdawS7DvQlSdLiELFc/pbmP8+TIi9Xk6uFsOaOjqxi2/XL8TLjmg==", 41 | "requires": { 42 | "async": "1.5.2", 43 | "lodash": "4.17.10", 44 | "request": "2.85.0", 45 | "xhr": "2.3.3" 46 | }, 47 | "dependencies": { 48 | "lodash": { 49 | "version": "4.17.10", 50 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", 51 | "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==" 52 | } 53 | } 54 | }, 55 | "ajv": { 56 | "version": "5.5.2", 57 | "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", 58 | "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", 59 | "requires": { 60 | "co": "^4.6.0", 61 | "fast-deep-equal": "^1.0.0", 62 | "fast-json-stable-stringify": "^2.0.0", 63 | "json-schema-traverse": "^0.3.0" 64 | } 65 | }, 66 | "array-flatten": { 67 | "version": "1.1.1", 68 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 69 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 70 | }, 71 | "asn1": { 72 | "version": "0.2.4", 73 | "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", 74 | "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", 75 | "requires": { 76 | "safer-buffer": "~2.1.0" 77 | } 78 | }, 79 | "assert-plus": { 80 | "version": "1.0.0", 81 | "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", 82 | "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" 83 | }, 84 | "assertion-error": { 85 | "version": "1.1.0", 86 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 87 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", 88 | "dev": true 89 | }, 90 | "async": { 91 | "version": "1.5.2", 92 | "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", 93 | "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" 94 | }, 95 | "asynckit": { 96 | "version": "0.4.0", 97 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 98 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 99 | }, 100 | "aws-sign2": { 101 | "version": "0.7.0", 102 | "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", 103 | "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" 104 | }, 105 | "aws4": { 106 | "version": "1.8.0", 107 | "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", 108 | "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" 109 | }, 110 | "balanced-match": { 111 | "version": "1.0.0", 112 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 113 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 114 | "dev": true 115 | }, 116 | "basic-auth": { 117 | "version": "2.0.0", 118 | "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", 119 | "integrity": "sha1-AV2z81PgLlY3d1X5YnQuiYHnu7o=", 120 | "dev": true, 121 | "requires": { 122 | "safe-buffer": "5.1.1" 123 | }, 124 | "dependencies": { 125 | "safe-buffer": { 126 | "version": "5.1.1", 127 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", 128 | "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", 129 | "dev": true 130 | } 131 | } 132 | }, 133 | "bcrypt-pbkdf": { 134 | "version": "1.0.2", 135 | "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", 136 | "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", 137 | "optional": true, 138 | "requires": { 139 | "tweetnacl": "^0.14.3" 140 | } 141 | }, 142 | "body-parser": { 143 | "version": "1.18.2", 144 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", 145 | "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", 146 | "requires": { 147 | "bytes": "3.0.0", 148 | "content-type": "~1.0.4", 149 | "debug": "2.6.9", 150 | "depd": "~1.1.1", 151 | "http-errors": "~1.6.2", 152 | "iconv-lite": "0.4.19", 153 | "on-finished": "~2.3.0", 154 | "qs": "6.5.1", 155 | "raw-body": "2.3.2", 156 | "type-is": "~1.6.15" 157 | }, 158 | "dependencies": { 159 | "qs": { 160 | "version": "6.5.1", 161 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", 162 | "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" 163 | } 164 | } 165 | }, 166 | "boom": { 167 | "version": "4.3.1", 168 | "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", 169 | "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", 170 | "requires": { 171 | "hoek": "4.x.x" 172 | } 173 | }, 174 | "brace-expansion": { 175 | "version": "1.1.11", 176 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 177 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 178 | "dev": true, 179 | "requires": { 180 | "balanced-match": "^1.0.0", 181 | "concat-map": "0.0.1" 182 | } 183 | }, 184 | "browser-stdout": { 185 | "version": "1.3.1", 186 | "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", 187 | "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", 188 | "dev": true 189 | }, 190 | "buffer-writer": { 191 | "version": "1.0.1", 192 | "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz", 193 | "integrity": "sha1-Iqk2kB4wKa/NdUfrRIfOtpejvwg=" 194 | }, 195 | "bytes": { 196 | "version": "3.0.0", 197 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", 198 | "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" 199 | }, 200 | "caseless": { 201 | "version": "0.12.0", 202 | "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", 203 | "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" 204 | }, 205 | "chai": { 206 | "version": "4.1.2", 207 | "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", 208 | "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", 209 | "dev": true, 210 | "requires": { 211 | "assertion-error": "^1.0.1", 212 | "check-error": "^1.0.1", 213 | "deep-eql": "^3.0.0", 214 | "get-func-name": "^2.0.0", 215 | "pathval": "^1.0.0", 216 | "type-detect": "^4.0.0" 217 | } 218 | }, 219 | "chai-http": { 220 | "version": "4.0.0", 221 | "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.0.0.tgz", 222 | "integrity": "sha512-R30Lj3JHHPhknOyurh09ZEBgyO4iSSeTjbLmyLvTr88IFC+zwRjAmaxBwj9TbEAGi0IV2uW+RHaTxeah5rdSaQ==", 223 | "dev": true, 224 | "requires": { 225 | "cookiejar": "^2.1.1", 226 | "is-ip": "^2.0.0", 227 | "methods": "^1.1.2", 228 | "qs": "^6.5.1", 229 | "superagent": "^3.7.0" 230 | } 231 | }, 232 | "check-error": { 233 | "version": "1.0.2", 234 | "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", 235 | "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", 236 | "dev": true 237 | }, 238 | "co": { 239 | "version": "4.6.0", 240 | "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", 241 | "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" 242 | }, 243 | "combined-stream": { 244 | "version": "1.0.6", 245 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", 246 | "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", 247 | "requires": { 248 | "delayed-stream": "~1.0.0" 249 | } 250 | }, 251 | "commander": { 252 | "version": "2.15.1", 253 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", 254 | "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", 255 | "dev": true 256 | }, 257 | "component-emitter": { 258 | "version": "1.2.1", 259 | "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", 260 | "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", 261 | "dev": true 262 | }, 263 | "concat-map": { 264 | "version": "0.0.1", 265 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 266 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 267 | "dev": true 268 | }, 269 | "content-disposition": { 270 | "version": "0.5.2", 271 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", 272 | "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" 273 | }, 274 | "content-type": { 275 | "version": "1.0.4", 276 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 277 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 278 | }, 279 | "cookie": { 280 | "version": "0.3.1", 281 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", 282 | "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" 283 | }, 284 | "cookie-signature": { 285 | "version": "1.0.6", 286 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 287 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 288 | }, 289 | "cookiejar": { 290 | "version": "2.1.2", 291 | "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", 292 | "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", 293 | "dev": true 294 | }, 295 | "core-util-is": { 296 | "version": "1.0.2", 297 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", 298 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" 299 | }, 300 | "cryptiles": { 301 | "version": "3.1.2", 302 | "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", 303 | "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", 304 | "requires": { 305 | "boom": "5.x.x" 306 | }, 307 | "dependencies": { 308 | "boom": { 309 | "version": "5.2.0", 310 | "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", 311 | "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", 312 | "requires": { 313 | "hoek": "4.x.x" 314 | } 315 | } 316 | } 317 | }, 318 | "dashdash": { 319 | "version": "1.14.1", 320 | "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", 321 | "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", 322 | "requires": { 323 | "assert-plus": "^1.0.0" 324 | } 325 | }, 326 | "debug": { 327 | "version": "2.6.9", 328 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 329 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 330 | "requires": { 331 | "ms": "2.0.0" 332 | } 333 | }, 334 | "deep-eql": { 335 | "version": "3.0.1", 336 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", 337 | "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", 338 | "dev": true, 339 | "requires": { 340 | "type-detect": "^4.0.0" 341 | } 342 | }, 343 | "delayed-stream": { 344 | "version": "1.0.0", 345 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 346 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 347 | }, 348 | "depd": { 349 | "version": "1.1.2", 350 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", 351 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" 352 | }, 353 | "destroy": { 354 | "version": "1.0.4", 355 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", 356 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 357 | }, 358 | "diff": { 359 | "version": "3.5.0", 360 | "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", 361 | "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" 362 | }, 363 | "dom-walk": { 364 | "version": "0.1.1", 365 | "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz", 366 | "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=" 367 | }, 368 | "ecc-jsbn": { 369 | "version": "0.1.2", 370 | "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", 371 | "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", 372 | "optional": true, 373 | "requires": { 374 | "jsbn": "~0.1.0", 375 | "safer-buffer": "^2.1.0" 376 | } 377 | }, 378 | "ee-first": { 379 | "version": "1.1.1", 380 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 381 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 382 | }, 383 | "encodeurl": { 384 | "version": "1.0.2", 385 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 386 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" 387 | }, 388 | "escape-html": { 389 | "version": "1.0.3", 390 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 391 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 392 | }, 393 | "escape-string-regexp": { 394 | "version": "1.0.5", 395 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 396 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 397 | "dev": true 398 | }, 399 | "etag": { 400 | "version": "1.8.1", 401 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 402 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" 403 | }, 404 | "express": { 405 | "version": "4.16.3", 406 | "resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz", 407 | "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", 408 | "requires": { 409 | "accepts": "~1.3.5", 410 | "array-flatten": "1.1.1", 411 | "body-parser": "1.18.2", 412 | "content-disposition": "0.5.2", 413 | "content-type": "~1.0.4", 414 | "cookie": "0.3.1", 415 | "cookie-signature": "1.0.6", 416 | "debug": "2.6.9", 417 | "depd": "~1.1.2", 418 | "encodeurl": "~1.0.2", 419 | "escape-html": "~1.0.3", 420 | "etag": "~1.8.1", 421 | "finalhandler": "1.1.1", 422 | "fresh": "0.5.2", 423 | "merge-descriptors": "1.0.1", 424 | "methods": "~1.1.2", 425 | "on-finished": "~2.3.0", 426 | "parseurl": "~1.3.2", 427 | "path-to-regexp": "0.1.7", 428 | "proxy-addr": "~2.0.3", 429 | "qs": "6.5.1", 430 | "range-parser": "~1.2.0", 431 | "safe-buffer": "5.1.1", 432 | "send": "0.16.2", 433 | "serve-static": "1.13.2", 434 | "setprototypeof": "1.1.0", 435 | "statuses": "~1.4.0", 436 | "type-is": "~1.6.16", 437 | "utils-merge": "1.0.1", 438 | "vary": "~1.1.2" 439 | }, 440 | "dependencies": { 441 | "qs": { 442 | "version": "6.5.1", 443 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", 444 | "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" 445 | }, 446 | "safe-buffer": { 447 | "version": "5.1.1", 448 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", 449 | "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" 450 | } 451 | } 452 | }, 453 | "extend": { 454 | "version": "3.0.2", 455 | "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", 456 | "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" 457 | }, 458 | "extsprintf": { 459 | "version": "1.3.0", 460 | "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", 461 | "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" 462 | }, 463 | "fast-deep-equal": { 464 | "version": "1.1.0", 465 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", 466 | "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" 467 | }, 468 | "fast-json-stable-stringify": { 469 | "version": "2.0.0", 470 | "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", 471 | "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" 472 | }, 473 | "finalhandler": { 474 | "version": "1.1.1", 475 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", 476 | "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", 477 | "requires": { 478 | "debug": "2.6.9", 479 | "encodeurl": "~1.0.2", 480 | "escape-html": "~1.0.3", 481 | "on-finished": "~2.3.0", 482 | "parseurl": "~1.3.2", 483 | "statuses": "~1.4.0", 484 | "unpipe": "~1.0.0" 485 | } 486 | }, 487 | "for-each": { 488 | "version": "0.3.3", 489 | "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", 490 | "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", 491 | "requires": { 492 | "is-callable": "^1.1.3" 493 | } 494 | }, 495 | "forever-agent": { 496 | "version": "0.6.1", 497 | "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", 498 | "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" 499 | }, 500 | "form-data": { 501 | "version": "2.3.2", 502 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", 503 | "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", 504 | "requires": { 505 | "asynckit": "^0.4.0", 506 | "combined-stream": "1.0.6", 507 | "mime-types": "^2.1.12" 508 | } 509 | }, 510 | "formidable": { 511 | "version": "1.2.1", 512 | "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", 513 | "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", 514 | "dev": true 515 | }, 516 | "forwarded": { 517 | "version": "0.1.2", 518 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", 519 | "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" 520 | }, 521 | "fresh": { 522 | "version": "0.5.2", 523 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 524 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" 525 | }, 526 | "fs.realpath": { 527 | "version": "1.0.0", 528 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 529 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 530 | "dev": true 531 | }, 532 | "get-func-name": { 533 | "version": "2.0.0", 534 | "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", 535 | "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", 536 | "dev": true 537 | }, 538 | "getpass": { 539 | "version": "0.1.7", 540 | "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", 541 | "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", 542 | "requires": { 543 | "assert-plus": "^1.0.0" 544 | } 545 | }, 546 | "glob": { 547 | "version": "7.1.2", 548 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 549 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 550 | "dev": true, 551 | "requires": { 552 | "fs.realpath": "^1.0.0", 553 | "inflight": "^1.0.4", 554 | "inherits": "2", 555 | "minimatch": "^3.0.4", 556 | "once": "^1.3.0", 557 | "path-is-absolute": "^1.0.0" 558 | } 559 | }, 560 | "global": { 561 | "version": "4.3.2", 562 | "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz", 563 | "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=", 564 | "requires": { 565 | "min-document": "^2.19.0", 566 | "process": "~0.5.1" 567 | } 568 | }, 569 | "growl": { 570 | "version": "1.10.5", 571 | "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", 572 | "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", 573 | "dev": true 574 | }, 575 | "har-schema": { 576 | "version": "2.0.0", 577 | "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", 578 | "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" 579 | }, 580 | "har-validator": { 581 | "version": "5.0.3", 582 | "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", 583 | "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", 584 | "requires": { 585 | "ajv": "^5.1.0", 586 | "har-schema": "^2.0.0" 587 | } 588 | }, 589 | "has-flag": { 590 | "version": "3.0.0", 591 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 592 | "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" 593 | }, 594 | "hawk": { 595 | "version": "6.0.2", 596 | "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", 597 | "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", 598 | "requires": { 599 | "boom": "4.x.x", 600 | "cryptiles": "3.x.x", 601 | "hoek": "4.x.x", 602 | "sntp": "2.x.x" 603 | } 604 | }, 605 | "he": { 606 | "version": "1.1.1", 607 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 608 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", 609 | "dev": true 610 | }, 611 | "hoek": { 612 | "version": "4.2.1", 613 | "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz", 614 | "integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA==" 615 | }, 616 | "http-errors": { 617 | "version": "1.6.3", 618 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", 619 | "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", 620 | "requires": { 621 | "depd": "~1.1.2", 622 | "inherits": "2.0.3", 623 | "setprototypeof": "1.1.0", 624 | "statuses": ">= 1.4.0 < 2" 625 | } 626 | }, 627 | "http-signature": { 628 | "version": "1.2.0", 629 | "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", 630 | "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", 631 | "requires": { 632 | "assert-plus": "^1.0.0", 633 | "jsprim": "^1.2.2", 634 | "sshpk": "^1.7.0" 635 | } 636 | }, 637 | "iconv-lite": { 638 | "version": "0.4.19", 639 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", 640 | "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" 641 | }, 642 | "inflight": { 643 | "version": "1.0.6", 644 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 645 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 646 | "dev": true, 647 | "requires": { 648 | "once": "^1.3.0", 649 | "wrappy": "1" 650 | } 651 | }, 652 | "inherits": { 653 | "version": "2.0.3", 654 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 655 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 656 | }, 657 | "ip-regex": { 658 | "version": "2.1.0", 659 | "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", 660 | "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", 661 | "dev": true 662 | }, 663 | "ipaddr.js": { 664 | "version": "1.8.0", 665 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", 666 | "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=" 667 | }, 668 | "is-callable": { 669 | "version": "1.1.4", 670 | "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", 671 | "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==" 672 | }, 673 | "is-function": { 674 | "version": "1.0.1", 675 | "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz", 676 | "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU=" 677 | }, 678 | "is-ip": { 679 | "version": "2.0.0", 680 | "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", 681 | "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", 682 | "dev": true, 683 | "requires": { 684 | "ip-regex": "^2.0.0" 685 | } 686 | }, 687 | "is-typedarray": { 688 | "version": "1.0.0", 689 | "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", 690 | "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" 691 | }, 692 | "isarray": { 693 | "version": "1.0.0", 694 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 695 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", 696 | "dev": true 697 | }, 698 | "isstream": { 699 | "version": "0.1.2", 700 | "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", 701 | "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" 702 | }, 703 | "jsbn": { 704 | "version": "0.1.1", 705 | "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", 706 | "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", 707 | "optional": true 708 | }, 709 | "json-schema": { 710 | "version": "0.2.3", 711 | "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", 712 | "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" 713 | }, 714 | "json-schema-traverse": { 715 | "version": "0.3.1", 716 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", 717 | "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" 718 | }, 719 | "json-stringify-safe": { 720 | "version": "5.0.1", 721 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", 722 | "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" 723 | }, 724 | "jsprim": { 725 | "version": "1.4.1", 726 | "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", 727 | "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", 728 | "requires": { 729 | "assert-plus": "1.0.0", 730 | "extsprintf": "1.3.0", 731 | "json-schema": "0.2.3", 732 | "verror": "1.10.0" 733 | } 734 | }, 735 | "just-extend": { 736 | "version": "1.1.27", 737 | "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", 738 | "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==" 739 | }, 740 | "lodash": { 741 | "version": "4.17.10", 742 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", 743 | "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==" 744 | }, 745 | "lodash.get": { 746 | "version": "4.4.2", 747 | "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", 748 | "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" 749 | }, 750 | "lolex": { 751 | "version": "2.7.1", 752 | "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", 753 | "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==" 754 | }, 755 | "media-typer": { 756 | "version": "0.3.0", 757 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 758 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 759 | }, 760 | "merge-descriptors": { 761 | "version": "1.0.1", 762 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", 763 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 764 | }, 765 | "methods": { 766 | "version": "1.1.2", 767 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 768 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 769 | }, 770 | "mime": { 771 | "version": "1.4.1", 772 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", 773 | "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" 774 | }, 775 | "mime-db": { 776 | "version": "1.35.0", 777 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", 778 | "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" 779 | }, 780 | "mime-types": { 781 | "version": "2.1.19", 782 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", 783 | "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", 784 | "requires": { 785 | "mime-db": "~1.35.0" 786 | } 787 | }, 788 | "min-document": { 789 | "version": "2.19.0", 790 | "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", 791 | "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", 792 | "requires": { 793 | "dom-walk": "^0.1.0" 794 | } 795 | }, 796 | "minimatch": { 797 | "version": "3.0.4", 798 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 799 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 800 | "dev": true, 801 | "requires": { 802 | "brace-expansion": "^1.1.7" 803 | } 804 | }, 805 | "minimist": { 806 | "version": "0.0.8", 807 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 808 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", 809 | "dev": true 810 | }, 811 | "mkdirp": { 812 | "version": "0.5.1", 813 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 814 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 815 | "dev": true, 816 | "requires": { 817 | "minimist": "0.0.8" 818 | } 819 | }, 820 | "mocha": { 821 | "version": "5.2.0", 822 | "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", 823 | "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", 824 | "dev": true, 825 | "requires": { 826 | "browser-stdout": "1.3.1", 827 | "commander": "2.15.1", 828 | "debug": "3.1.0", 829 | "diff": "3.5.0", 830 | "escape-string-regexp": "1.0.5", 831 | "glob": "7.1.2", 832 | "growl": "1.10.5", 833 | "he": "1.1.1", 834 | "minimatch": "3.0.4", 835 | "mkdirp": "0.5.1", 836 | "supports-color": "5.4.0" 837 | }, 838 | "dependencies": { 839 | "debug": { 840 | "version": "3.1.0", 841 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 842 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 843 | "dev": true, 844 | "requires": { 845 | "ms": "2.0.0" 846 | } 847 | } 848 | } 849 | }, 850 | "morgan": { 851 | "version": "1.9.0", 852 | "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz", 853 | "integrity": "sha1-0B+mxlhZt2/PMbPLU6OCGjEdgFE=", 854 | "dev": true, 855 | "requires": { 856 | "basic-auth": "~2.0.0", 857 | "debug": "2.6.9", 858 | "depd": "~1.1.1", 859 | "on-finished": "~2.3.0", 860 | "on-headers": "~1.0.1" 861 | } 862 | }, 863 | "ms": { 864 | "version": "2.0.0", 865 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 866 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 867 | }, 868 | "negotiator": { 869 | "version": "0.6.1", 870 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", 871 | "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" 872 | }, 873 | "nise": { 874 | "version": "1.4.2", 875 | "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.2.tgz", 876 | "integrity": "sha512-BxH/DxoQYYdhKgVAfqVy4pzXRZELHOIewzoesxpjYvpU+7YOalQhGNPf7wAx8pLrTNPrHRDlLOkAl8UI0ZpXjw==", 877 | "requires": { 878 | "@sinonjs/formatio": "^2.0.0", 879 | "just-extend": "^1.1.27", 880 | "lolex": "^2.3.2", 881 | "path-to-regexp": "^1.7.0", 882 | "text-encoding": "^0.6.4" 883 | }, 884 | "dependencies": { 885 | "isarray": { 886 | "version": "0.0.1", 887 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", 888 | "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" 889 | }, 890 | "path-to-regexp": { 891 | "version": "1.7.0", 892 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", 893 | "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", 894 | "requires": { 895 | "isarray": "0.0.1" 896 | } 897 | } 898 | } 899 | }, 900 | "oauth-sign": { 901 | "version": "0.8.2", 902 | "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", 903 | "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" 904 | }, 905 | "object-hash": { 906 | "version": "1.3.0", 907 | "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.0.tgz", 908 | "integrity": "sha512-05KzQ70lSeGSrZJQXE5wNDiTkBJDlUT/myi6RX9dVIvz7a7Qh4oH93BQdiPMn27nldYvVQCKMUaM83AfizZlsQ==" 909 | }, 910 | "on-finished": { 911 | "version": "2.3.0", 912 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 913 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 914 | "requires": { 915 | "ee-first": "1.1.1" 916 | } 917 | }, 918 | "on-headers": { 919 | "version": "1.0.1", 920 | "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", 921 | "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=", 922 | "dev": true 923 | }, 924 | "once": { 925 | "version": "1.4.0", 926 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 927 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 928 | "dev": true, 929 | "requires": { 930 | "wrappy": "1" 931 | } 932 | }, 933 | "packet-reader": { 934 | "version": "0.3.1", 935 | "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", 936 | "integrity": "sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc=" 937 | }, 938 | "parse-headers": { 939 | "version": "2.0.1", 940 | "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.1.tgz", 941 | "integrity": "sha1-aug6eqJanZtwCswoaYzR8e1+lTY=", 942 | "requires": { 943 | "for-each": "^0.3.2", 944 | "trim": "0.0.1" 945 | } 946 | }, 947 | "parseurl": { 948 | "version": "1.3.2", 949 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", 950 | "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" 951 | }, 952 | "path-is-absolute": { 953 | "version": "1.0.1", 954 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 955 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 956 | "dev": true 957 | }, 958 | "path-to-regexp": { 959 | "version": "0.1.7", 960 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", 961 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 962 | }, 963 | "pathval": { 964 | "version": "1.1.0", 965 | "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", 966 | "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", 967 | "dev": true 968 | }, 969 | "performance-now": { 970 | "version": "2.1.0", 971 | "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", 972 | "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" 973 | }, 974 | "pg": { 975 | "version": "7.4.3", 976 | "resolved": "https://registry.npmjs.org/pg/-/pg-7.4.3.tgz", 977 | "integrity": "sha1-97b5P1NA7MJZavu5ShPj1rYJg0s=", 978 | "requires": { 979 | "buffer-writer": "1.0.1", 980 | "packet-reader": "0.3.1", 981 | "pg-connection-string": "0.1.3", 982 | "pg-pool": "~2.0.3", 983 | "pg-types": "~1.12.1", 984 | "pgpass": "1.x", 985 | "semver": "4.3.2" 986 | } 987 | }, 988 | "pg-connection-string": { 989 | "version": "0.1.3", 990 | "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", 991 | "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" 992 | }, 993 | "pg-pool": { 994 | "version": "2.0.3", 995 | "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-2.0.3.tgz", 996 | "integrity": "sha1-wCIDLIlJ8xKk+R+2QJzgQHa+Mlc=" 997 | }, 998 | "pg-types": { 999 | "version": "1.12.1", 1000 | "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.12.1.tgz", 1001 | "integrity": "sha1-1kCH45A7WP+q0nnnWVxSIIoUw9I=", 1002 | "requires": { 1003 | "postgres-array": "~1.0.0", 1004 | "postgres-bytea": "~1.0.0", 1005 | "postgres-date": "~1.0.0", 1006 | "postgres-interval": "^1.1.0" 1007 | } 1008 | }, 1009 | "pgpass": { 1010 | "version": "1.0.2", 1011 | "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", 1012 | "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", 1013 | "requires": { 1014 | "split": "^1.0.0" 1015 | } 1016 | }, 1017 | "postgres-array": { 1018 | "version": "1.0.2", 1019 | "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.2.tgz", 1020 | "integrity": "sha1-jgsy6wO/d6XAp4UeBEHBaaJWojg=" 1021 | }, 1022 | "postgres-bytea": { 1023 | "version": "1.0.0", 1024 | "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", 1025 | "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" 1026 | }, 1027 | "postgres-date": { 1028 | "version": "1.0.3", 1029 | "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", 1030 | "integrity": "sha1-4tiXAu/bJY/52c7g/pG9BpdSV6g=" 1031 | }, 1032 | "postgres-interval": { 1033 | "version": "1.1.2", 1034 | "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.2.tgz", 1035 | "integrity": "sha512-fC3xNHeTskCxL1dC8KOtxXt7YeFmlbTYtn7ul8MkVERuTmf7pI4DrkAxcw3kh1fQ9uz4wQmd03a1mRiXUZChfQ==", 1036 | "requires": { 1037 | "xtend": "^4.0.0" 1038 | } 1039 | }, 1040 | "process": { 1041 | "version": "0.5.2", 1042 | "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz", 1043 | "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=" 1044 | }, 1045 | "process-nextick-args": { 1046 | "version": "2.0.0", 1047 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", 1048 | "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", 1049 | "dev": true 1050 | }, 1051 | "proxy-addr": { 1052 | "version": "2.0.4", 1053 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", 1054 | "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", 1055 | "requires": { 1056 | "forwarded": "~0.1.2", 1057 | "ipaddr.js": "1.8.0" 1058 | } 1059 | }, 1060 | "punycode": { 1061 | "version": "1.4.1", 1062 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", 1063 | "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" 1064 | }, 1065 | "qs": { 1066 | "version": "6.5.2", 1067 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", 1068 | "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" 1069 | }, 1070 | "range-parser": { 1071 | "version": "1.2.0", 1072 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", 1073 | "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" 1074 | }, 1075 | "raw-body": { 1076 | "version": "2.3.2", 1077 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", 1078 | "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", 1079 | "requires": { 1080 | "bytes": "3.0.0", 1081 | "http-errors": "1.6.2", 1082 | "iconv-lite": "0.4.19", 1083 | "unpipe": "1.0.0" 1084 | }, 1085 | "dependencies": { 1086 | "depd": { 1087 | "version": "1.1.1", 1088 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", 1089 | "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" 1090 | }, 1091 | "http-errors": { 1092 | "version": "1.6.2", 1093 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", 1094 | "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", 1095 | "requires": { 1096 | "depd": "1.1.1", 1097 | "inherits": "2.0.3", 1098 | "setprototypeof": "1.0.3", 1099 | "statuses": ">= 1.3.1 < 2" 1100 | } 1101 | }, 1102 | "setprototypeof": { 1103 | "version": "1.0.3", 1104 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", 1105 | "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" 1106 | } 1107 | } 1108 | }, 1109 | "readable-stream": { 1110 | "version": "2.3.6", 1111 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", 1112 | "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", 1113 | "dev": true, 1114 | "requires": { 1115 | "core-util-is": "~1.0.0", 1116 | "inherits": "~2.0.3", 1117 | "isarray": "~1.0.0", 1118 | "process-nextick-args": "~2.0.0", 1119 | "safe-buffer": "~5.1.1", 1120 | "string_decoder": "~1.1.1", 1121 | "util-deprecate": "~1.0.1" 1122 | } 1123 | }, 1124 | "request": { 1125 | "version": "2.85.0", 1126 | "resolved": "https://registry.npmjs.org/request/-/request-2.85.0.tgz", 1127 | "integrity": "sha512-8H7Ehijd4js+s6wuVPLjwORxD4zeuyjYugprdOXlPSqaApmL/QOy+EB/beICHVCHkGMKNh5rvihb5ov+IDw4mg==", 1128 | "requires": { 1129 | "aws-sign2": "~0.7.0", 1130 | "aws4": "^1.6.0", 1131 | "caseless": "~0.12.0", 1132 | "combined-stream": "~1.0.5", 1133 | "extend": "~3.0.1", 1134 | "forever-agent": "~0.6.1", 1135 | "form-data": "~2.3.1", 1136 | "har-validator": "~5.0.3", 1137 | "hawk": "~6.0.2", 1138 | "http-signature": "~1.2.0", 1139 | "is-typedarray": "~1.0.0", 1140 | "isstream": "~0.1.2", 1141 | "json-stringify-safe": "~5.0.1", 1142 | "mime-types": "~2.1.17", 1143 | "oauth-sign": "~0.8.2", 1144 | "performance-now": "^2.1.0", 1145 | "qs": "~6.5.1", 1146 | "safe-buffer": "^5.1.1", 1147 | "stringstream": "~0.0.5", 1148 | "tough-cookie": "~2.3.3", 1149 | "tunnel-agent": "^0.6.0", 1150 | "uuid": "^3.1.0" 1151 | } 1152 | }, 1153 | "safe-buffer": { 1154 | "version": "5.1.2", 1155 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 1156 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 1157 | }, 1158 | "safer-buffer": { 1159 | "version": "2.1.2", 1160 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 1161 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1162 | }, 1163 | "samsam": { 1164 | "version": "1.3.0", 1165 | "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", 1166 | "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==" 1167 | }, 1168 | "semver": { 1169 | "version": "4.3.2", 1170 | "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", 1171 | "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" 1172 | }, 1173 | "send": { 1174 | "version": "0.16.2", 1175 | "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", 1176 | "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", 1177 | "requires": { 1178 | "debug": "2.6.9", 1179 | "depd": "~1.1.2", 1180 | "destroy": "~1.0.4", 1181 | "encodeurl": "~1.0.2", 1182 | "escape-html": "~1.0.3", 1183 | "etag": "~1.8.1", 1184 | "fresh": "0.5.2", 1185 | "http-errors": "~1.6.2", 1186 | "mime": "1.4.1", 1187 | "ms": "2.0.0", 1188 | "on-finished": "~2.3.0", 1189 | "range-parser": "~1.2.0", 1190 | "statuses": "~1.4.0" 1191 | } 1192 | }, 1193 | "serve-static": { 1194 | "version": "1.13.2", 1195 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", 1196 | "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", 1197 | "requires": { 1198 | "encodeurl": "~1.0.2", 1199 | "escape-html": "~1.0.3", 1200 | "parseurl": "~1.3.2", 1201 | "send": "0.16.2" 1202 | } 1203 | }, 1204 | "setprototypeof": { 1205 | "version": "1.1.0", 1206 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", 1207 | "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" 1208 | }, 1209 | "sinon": { 1210 | "version": "6.1.5", 1211 | "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.1.5.tgz", 1212 | "integrity": "sha512-TcbRoWs1SdY6NOqfj0c9OEQquBoZH+qEf8799m1jjcbfWrrpyCQ3B/BpX7+NKa7Vn33Jl+Z50H4Oys3bzygK2Q==", 1213 | "requires": { 1214 | "@sinonjs/commons": "^1.0.1", 1215 | "@sinonjs/formatio": "^2.0.0", 1216 | "@sinonjs/samsam": "^2.0.0", 1217 | "diff": "^3.5.0", 1218 | "lodash.get": "^4.4.2", 1219 | "lolex": "^2.7.1", 1220 | "nise": "^1.4.2", 1221 | "supports-color": "^5.4.0", 1222 | "type-detect": "^4.0.8" 1223 | } 1224 | }, 1225 | "sntp": { 1226 | "version": "2.1.0", 1227 | "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", 1228 | "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", 1229 | "requires": { 1230 | "hoek": "4.x.x" 1231 | } 1232 | }, 1233 | "split": { 1234 | "version": "1.0.1", 1235 | "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", 1236 | "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", 1237 | "requires": { 1238 | "through": "2" 1239 | } 1240 | }, 1241 | "sshpk": { 1242 | "version": "1.14.2", 1243 | "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", 1244 | "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", 1245 | "requires": { 1246 | "asn1": "~0.2.3", 1247 | "assert-plus": "^1.0.0", 1248 | "bcrypt-pbkdf": "^1.0.0", 1249 | "dashdash": "^1.12.0", 1250 | "ecc-jsbn": "~0.1.1", 1251 | "getpass": "^0.1.1", 1252 | "jsbn": "~0.1.0", 1253 | "safer-buffer": "^2.0.2", 1254 | "tweetnacl": "~0.14.0" 1255 | } 1256 | }, 1257 | "statuses": { 1258 | "version": "1.4.0", 1259 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", 1260 | "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" 1261 | }, 1262 | "string_decoder": { 1263 | "version": "1.1.1", 1264 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", 1265 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", 1266 | "dev": true, 1267 | "requires": { 1268 | "safe-buffer": "~5.1.0" 1269 | } 1270 | }, 1271 | "stringstream": { 1272 | "version": "0.0.6", 1273 | "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", 1274 | "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==" 1275 | }, 1276 | "superagent": { 1277 | "version": "3.8.3", 1278 | "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", 1279 | "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", 1280 | "dev": true, 1281 | "requires": { 1282 | "component-emitter": "^1.2.0", 1283 | "cookiejar": "^2.1.0", 1284 | "debug": "^3.1.0", 1285 | "extend": "^3.0.0", 1286 | "form-data": "^2.3.1", 1287 | "formidable": "^1.2.0", 1288 | "methods": "^1.1.1", 1289 | "mime": "^1.4.1", 1290 | "qs": "^6.5.1", 1291 | "readable-stream": "^2.3.5" 1292 | }, 1293 | "dependencies": { 1294 | "debug": { 1295 | "version": "3.1.0", 1296 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 1297 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 1298 | "dev": true, 1299 | "requires": { 1300 | "ms": "2.0.0" 1301 | } 1302 | } 1303 | } 1304 | }, 1305 | "supports-color": { 1306 | "version": "5.4.0", 1307 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", 1308 | "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", 1309 | "requires": { 1310 | "has-flag": "^3.0.0" 1311 | } 1312 | }, 1313 | "text-encoding": { 1314 | "version": "0.6.4", 1315 | "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", 1316 | "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" 1317 | }, 1318 | "through": { 1319 | "version": "2.3.8", 1320 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 1321 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 1322 | }, 1323 | "tough-cookie": { 1324 | "version": "2.3.4", 1325 | "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", 1326 | "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", 1327 | "requires": { 1328 | "punycode": "^1.4.1" 1329 | } 1330 | }, 1331 | "trim": { 1332 | "version": "0.0.1", 1333 | "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", 1334 | "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" 1335 | }, 1336 | "tunnel-agent": { 1337 | "version": "0.6.0", 1338 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 1339 | "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", 1340 | "requires": { 1341 | "safe-buffer": "^5.0.1" 1342 | } 1343 | }, 1344 | "tweetnacl": { 1345 | "version": "0.14.5", 1346 | "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", 1347 | "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", 1348 | "optional": true 1349 | }, 1350 | "type-detect": { 1351 | "version": "4.0.8", 1352 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 1353 | "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" 1354 | }, 1355 | "type-is": { 1356 | "version": "1.6.16", 1357 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", 1358 | "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", 1359 | "requires": { 1360 | "media-typer": "0.3.0", 1361 | "mime-types": "~2.1.18" 1362 | } 1363 | }, 1364 | "unpipe": { 1365 | "version": "1.0.0", 1366 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 1367 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 1368 | }, 1369 | "util-deprecate": { 1370 | "version": "1.0.2", 1371 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 1372 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", 1373 | "dev": true 1374 | }, 1375 | "utils-merge": { 1376 | "version": "1.0.1", 1377 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 1378 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" 1379 | }, 1380 | "uuid": { 1381 | "version": "3.3.2", 1382 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", 1383 | "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" 1384 | }, 1385 | "vary": { 1386 | "version": "1.1.2", 1387 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 1388 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" 1389 | }, 1390 | "verror": { 1391 | "version": "1.10.0", 1392 | "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", 1393 | "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", 1394 | "requires": { 1395 | "assert-plus": "^1.0.0", 1396 | "core-util-is": "1.0.2", 1397 | "extsprintf": "^1.2.0" 1398 | } 1399 | }, 1400 | "wrappy": { 1401 | "version": "1.0.2", 1402 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 1403 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 1404 | "dev": true 1405 | }, 1406 | "xhr": { 1407 | "version": "2.3.3", 1408 | "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.3.3.tgz", 1409 | "integrity": "sha1-rWuBDgkXznK17HBPXUHxUDuOdSQ=", 1410 | "requires": { 1411 | "global": "~4.3.0", 1412 | "is-function": "^1.0.1", 1413 | "parse-headers": "^2.0.0", 1414 | "xtend": "^4.0.0" 1415 | } 1416 | }, 1417 | "xtend": { 1418 | "version": "4.0.1", 1419 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", 1420 | "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" 1421 | } 1422 | } 1423 | } 1424 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airtable-postgres-graphql", 3 | "version": "0.5.16", 4 | "dependencies": { 5 | "airtable": "^0.5.6", 6 | "express": "^4.16.3", 7 | "lodash": "^4.17.10", 8 | "object-hash": "^1.3.0", 9 | "pegjs": "^0.10.0", 10 | "pg": "^7.4.3", 11 | "sinon": "^6.1.5" 12 | }, 13 | "scripts": { 14 | "start": "node src/rest.js", 15 | "test": "docker-compose up --build", 16 | "test-in-docker": "mocha -S", 17 | "test-backup": "mocha test/01-restore.js" 18 | }, 19 | "devDependencies": { 20 | "morgan": "^1.9.0", 21 | "chai": "^4.1.2", 22 | "chai-http": "^4.0.0", 23 | "mocha": "^5.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const { formulaToSql, sortToSql } = require('./formula'); 3 | const Airtable = require('airtable'); 4 | const _ = require('lodash'); 5 | const Syncronizer = require('./sync'); 6 | 7 | const pool = new Pool({ 8 | connectionString: process.env.DATABASE_URL 9 | }); 10 | 11 | class AirtableRest { 12 | constructor(config) { 13 | this.config = config; 14 | this.base = new Airtable({ apiKey: config.apiKey }).base(config.base); 15 | this.onChangeHooks = []; 16 | this.onSelectHooks = [(user, table, res) => res]; 17 | this.schema = this.config.schema; 18 | this.sync = new Syncronizer(config, this.onChangeHooks); 19 | this.assignUser = _.identity; 20 | } 21 | 22 | setupPeriodicUpdate() { 23 | return this.sync.setupPeriodicUpdate(this.config, this.onChangeHooks); 24 | } 25 | 26 | validateTable(table) { 27 | if (!_.includes(this.config.tables, table)) 28 | throw new Error(`Table ${table} not available`); 29 | } 30 | 31 | prepareResult(user, table, entity) { 32 | const convertedKeys = _.mapKeys(entity, (v, k) => _.camelCase(k)); 33 | const appliedHooks = _.reduce(this.onSelectHooks, (result, fn) => fn(user, table, result), convertedKeys); 34 | return appliedHooks; 35 | } 36 | 37 | async listRecords(req, res) { 38 | const table = req.params.table; 39 | const user = this.assignUser(req, res); 40 | 41 | this.validateTable(table); 42 | 43 | const maxRecords = parseInt(req.query.maxRecords); 44 | let pageSize = parseInt(req.query.pageSize) || 100; 45 | const offset = parseInt(req.query.offset) || 0; 46 | 47 | if (maxRecords && (offset + pageSize) > maxRecords) { 48 | pageSize = maxRecords - offset; 49 | } 50 | 51 | const filter = formulaToSql(req.query.filterByFormula); 52 | const sort = sortToSql(req.query.sort); 53 | const query = `SELECT id,fields,created_time FROM ${this.sync.toPgTable(table)} WHERE ${filter} ORDER BY ${sort} LIMIT ${pageSize+1} OFFSET ${offset}`; 54 | console.log(query); 55 | const result = (await pool.query(query)).rows; 56 | const moreRecords = result.length > pageSize; 57 | const skipFirstRecords = moreRecords ? _.initial : _.identity; 58 | res.json({ records: _.map(skipFirstRecords(result), this.prepareResult.bind(this, user, table)), offset: moreRecords ? (pageSize + offset) : null}); 59 | } 60 | 61 | async createRecord(req, res) { 62 | const table = req.params.table; 63 | 64 | this.validateTable(table); 65 | 66 | const result = await this.base(table).create(req.body.fields); 67 | await this.sync.syncTable(table, result.id); 68 | res.json(result['_rawJson']); 69 | } 70 | 71 | async retrieveRecord(req, res) { 72 | const table = req.params.table; 73 | const user = this.assignUser(req, res); 74 | 75 | const id = req.params.id; 76 | 77 | this.validateTable(table); 78 | 79 | const query = `SELECT id,fields,created_time FROM ${this.sync.toPgTable(table)} WHERE id='${id}'`; 80 | const result = await pool.query(query); 81 | if (result.rows.length) res.json(this.prepareResult(user, table, result.rows[0])); 82 | else res.status(404).send('Not found'); 83 | } 84 | 85 | async deleteRecord(req, res) { 86 | const table = req.params.table; 87 | const id = req.params.id; 88 | const result = await this.base(table).destroy(id); 89 | await this.sync.syncTable(table); 90 | res.json({deleted: true, id: result.id}); 91 | } 92 | 93 | async updateRecord(req, res) { 94 | const table = req.params.table; 95 | const id = req.params.id; 96 | const result = await this.base(table).update(id, req.body.fields); 97 | await this.sync.syncTable(table, id); 98 | res.json(result['_rawJson']); 99 | } 100 | 101 | onChange(handler) { 102 | this.onChangeHooks.push(handler); 103 | } 104 | 105 | onSelect(handler) { 106 | this.onSelectHooks.push(handler); 107 | } 108 | 109 | onAssignUser(handler) { 110 | this.assignUser = handler; 111 | } 112 | } 113 | 114 | module.exports = AirtableRest; -------------------------------------------------------------------------------- /src/airtable.peg: -------------------------------------------------------------------------------- 1 | { 2 | function extractList(list, index) { 3 | return list.map(function(element) { return element[index]; }); 4 | } 5 | 6 | function buildList(head, tail, index) { 7 | return [head].concat(extractList(tail, index)); 8 | } 9 | 10 | function binop(op,left,right,type) { 11 | return {binop:op, left: left, right: right, type: type}; 12 | } 13 | } 14 | 15 | start = equality 16 | 17 | funcall = funname:fun "(" args:arguments ")" {return {fun:funname.toLowerCase(), args: args}}; 18 | 19 | arguments 20 | = head:start tail:(_ "," _ start)* {return buildList(head, tail, 3);} 21 | / _ 22 | 23 | equality = left:relational _ op:eq _ right:equality { return binop(op,left,right,'rel') } 24 | / relational 25 | 26 | relational = left:concat _ op:rel _ right:relational { return binop(op,left,right,'rel') } 27 | / concat 28 | 29 | concat = left:additive _ op:cat _ right:concat { return binop(op,left,right,'str') } 30 | / additive 31 | 32 | additive 33 | = left:multiplicative _ op:add _ right:additive { return binop(op,left,right,'math') } 34 | / multiplicative 35 | 36 | multiplicative 37 | = left:primary _ op:mul _ right:multiplicative { return binop(op,left,right,'math') } 38 | / primary 39 | 40 | primary 41 | = integer 42 | / string 43 | / funcall 44 | / variable 45 | / "(" parens:start ")" { return parens; } 46 | 47 | mul = "*" / "/" 48 | add = "+" / "-" 49 | cat = "&" 50 | rel = ">=" / "<=" / ">" / "<" 51 | eq = "=" / "!=" 52 | 53 | integer "integer" = digits:[-0-9.]+ { return {num: digits.join('')} } 54 | fun "fun" = name:[a-zA-Z_]+ {return name.join("");} 55 | _ "whitespace" = [ \t\n\r]* 56 | 57 | string "string" 58 | = '"' chars:doubleChar* '"' { return { str: chars.join("") };} 59 | / "'" chars:singleChar* "'" { return { str: chars.join("") };} 60 | 61 | doubleChar = !('"' / "\\" ) . { return text(); } 62 | singleChar = !("'" / "\\" ) . { return text(); } 63 | 64 | variable "variable" 65 | = '{' name:[^}]+ '}' { return {variable: name.join("")} } 66 | / name:[a-zA-Z_-]+ { return {variable: name.join("")} } -------------------------------------------------------------------------------- /src/audit.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshcodeIT/airtable-rest-graphql-postgres/b7568bfe1ceb2b35bc663c5e066ef8e24299d9af/src/audit.js -------------------------------------------------------------------------------- /src/formula.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const peg = require("pegjs"); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const parser = peg.generate(fs.readFileSync(path.resolve(__dirname, 'airtable.peg'), 'utf8'), { cache: true }); 7 | 8 | // TODO : allow only Aritable functions(can check by requesting list of functions from schema) 9 | 10 | function treeToSql({ binop, fun, args, left, right, variable, str, num, type }) { 11 | if (fun) { 12 | const sqlArguments = _.map(args, treeToSql); 13 | switch (fun) { 14 | case 'and': 15 | return `(${sqlArguments.map(arg => `${arg}::boolean`).join(' AND ')})`; 16 | case 'or': 17 | return `(${sqlArguments.map(arg => `${arg}::boolean`).join(' OR ')})`; 18 | case 'true': 19 | return `true`; 20 | case 'false': 21 | return `false`; 22 | case 'record_id': 23 | return `id`; 24 | case 'if': 25 | return `CASE (${sqlArguments[0]}::boolean) 26 | WHEN TRUE THEN (${sqlArguments[1]}) 27 | ${sqlArguments[2] ? `ELSE (${sqlArguments[2]})` : ''} 28 | END` 29 | default: 30 | return `(${fun}(${sqlArguments.join(',')}))`; 31 | } 32 | } 33 | else if (binop) { 34 | if (type === 'str') { 35 | return `((${treeToSql(left)}) || (${treeToSql(right)}))`; 36 | } 37 | else { 38 | return `JSON_BINOP('${type}','${binop}',${treeToSql(left)}::text, ${treeToSql(right)}::text)` 39 | } 40 | } 41 | else if (variable) { 42 | return `fields->>'${variable}'`; 43 | } 44 | else if (str) { 45 | return `'${str}'::text`; 46 | } 47 | else if (num) { 48 | return `(${num})`; 49 | } 50 | } 51 | 52 | function formulaToSql(formula) { 53 | if (formula) { 54 | console.log("Formula:" + formula); 55 | const parsedTree = parser.parse(formula.trim()); 56 | return `${treeToSql(parsedTree)}::boolean`; 57 | } 58 | else 59 | return "TRUE"; 60 | } 61 | 62 | function sortToSql(fields) { 63 | // TODO : add validation by fields 64 | if (!fields || !fields.length) 65 | return "id"; 66 | else 67 | return _.map(fields, ({ field, direction }) => `fields->'${field}' ${direction}`).join(','); 68 | } 69 | 70 | module.exports = { formulaToSql, sortToSql }; 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/functions.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION is_valid_json(p_json text) 2 | RETURNS BOOLEAN 3 | AS 4 | $$ 5 | BEGIN 6 | return (p_json::json is not null); 7 | exception 8 | when others then 9 | return false; 10 | END; 11 | $$ 12 | LANGUAGE PLPGSQL 13 | IMMUTABLE; 14 | 15 | CREATE OR REPLACE FUNCTION numeric_to_boolean(num numeric) RETURNS BOOLEAN 16 | AS 17 | $$ 18 | BEGIN 19 | return num>0; 20 | END; 21 | $$ 22 | LANGUAGE PLPGSQL 23 | IMMUTABLE; 24 | 25 | DROP CAST IF EXISTS (numeric AS boolean); 26 | CREATE CAST (numeric AS boolean) WITH FUNCTION numeric_to_boolean(numeric) AS IMPLICIT; 27 | 28 | CREATE OR REPLACE FUNCTION ARRAYUNIQUE(data text) RETURNS jsonb 29 | AS $$ SELECT jsonb_agg(distinct elem) FROM jsonb_array_elements(data::jsonb) elem $$ 30 | LANGUAGE SQL 31 | IMMUTABLE 32 | RETURNS NULL ON NULL INPUT; 33 | 34 | CREATE OR REPLACE FUNCTION ARRAYCOMPACT(data text) RETURNS jsonb 35 | AS $$ SELECT jsonb_agg(elem) FROM jsonb_array_elements(data::jsonb) elem WHERE NOT elem='null' $$ 36 | LANGUAGE SQL 37 | IMMUTABLE 38 | RETURNS NULL ON NULL INPUT; 39 | 40 | CREATE OR REPLACE FUNCTION ARRAYJOIN(data text, separator text) RETURNS text 41 | AS $$ SELECT string_agg(trim(elem::text, '"'), separator) FROM jsonb_array_elements(data::jsonb) elem $$ 42 | LANGUAGE SQL 43 | IMMUTABLE 44 | RETURNS NULL ON NULL INPUT; 45 | 46 | CREATE OR REPLACE FUNCTION FIND(stringToFind text, whereToSearch text,startFromPosition integer default 0) RETURNS integer 47 | AS $$ SELECT position(stringToFind in substring(whereToSearch from startFromPosition)) $$ 48 | LANGUAGE SQL 49 | IMMUTABLE 50 | RETURNS NULL ON NULL INPUT; 51 | 52 | CREATE OR REPLACE FUNCTION JSON_BINOP(type text, op text, leftOp text, rightOp text) RETURNS numeric LANGUAGE plpgsql STABLE AS $_$ 53 | DECLARE 54 | result numeric; 55 | BEGIN 56 | CASE 57 | 58 | WHEN type='math' THEN 59 | CASE 60 | WHEN leftOp='' AND rightOp='' THEN result:=0; 61 | WHEN leftOp='' THEN result:=rightOp; 62 | WHEN rightOp='' THEN result:=leftOp; 63 | WHEN leftOp ~ '^-?\d+(.\d+)?$' AND rightOp ~ '^-?\d+(.\d+)?$' THEN 64 | EXECUTE format('SELECT $1 %s $2', op) USING leftOp::numeric, rightOp::numeric INTO result; 65 | END CASE; 66 | 67 | when type='rel' THEN 68 | CASE 69 | WHEN leftOp ~ '^-?\d+(.\d+)?$' AND rightOp ~ '^-?\d+(.\d+)?$' THEN 70 | EXECUTE format('SELECT ($1 %s $2)::integer', op) USING leftOp::numeric, rightOp::numeric INTO result; 71 | WHEN is_valid_json(leftOp) AND jsonb_typeof(leftOp::jsonb)='array' THEN 72 | SELECT JSON_BINOP(type,op,ARRAYJOIN(leftOp, ','),rightOp) INTO result; 73 | WHEN is_valid_json(rightOp) AND jsonb_typeof(rightOp::jsonb)='array' THEN 74 | SELECT JSON_BINOP(type,op,leftOp,ARRAYJOIN(rightOp,',')) INTO result; 75 | ELSE 76 | EXECUTE format('SELECT ($1 %s $2)::integer', op) USING leftOp::text, rightOp::text INTO result; 77 | END CASE; 78 | 79 | END CASE; 80 | RETURN result; 81 | END; 82 | $_$; 83 | 84 | CREATE OR REPLACE FUNCTION IS_BEFORE(first_date text, second_date text) RETURNS BOOLEAN 85 | AS $$ SELECT first_date <= second_date $$ 86 | LANGUAGE SQL 87 | IMMUTABLE 88 | RETURNS NULL ON NULL INPUT; 89 | 90 | CREATE OR REPLACE FUNCTION IS_SAME(first_date text, second_date text) RETURNS BOOLEAN 91 | AS $$ SELECT first_date = second_date $$ 92 | LANGUAGE SQL 93 | IMMUTABLE 94 | RETURNS NULL ON NULL INPUT; 95 | -------------------------------------------------------------------------------- /src/restore.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const Airtable = require('airtable'); 3 | const _ = require('lodash'); 4 | 5 | const pool = new Pool({ 6 | connectionString: process.env.DATABASE_URL 7 | }); 8 | 9 | function cleanObjectFromFk(object, allIds) { 10 | return { 11 | id: object.id, 12 | __tableName: object.__tableName, 13 | fields: _.mapValues(object.fields, (value) => { 14 | if (_.isArray(value)) { 15 | if (_.every(value, _.isObject)) 16 | return _.map(value, ({ url, filename }) => ({ url, filename })); 17 | else 18 | return _.difference(value, allIds); 19 | } 20 | else if (_.includes(allIds, value)) 21 | return null; 22 | return value; 23 | }) 24 | }; 25 | } 26 | 27 | function replaceOldFKtoNewFK(fields, oldIdToNewMapping) { 28 | return _.pickBy(_.mapValues(fields, (value) => { 29 | if (_.isArray(value)) { 30 | if (_.every(value, _.isObject)) 31 | return null; 32 | else 33 | return _.map(value, (val) => oldIdToNewMapping[val]); 34 | } 35 | else 36 | return oldIdToNewMapping[value]; 37 | })); 38 | } 39 | 40 | function toPgTable(schema, table) { 41 | return schema + '.' + table.replace(/ /,'_'); 42 | } 43 | 44 | async function getAllObjects(schema, tables) { 45 | const allObectsGrouped = await Promise.all(_.map(tables, async (table) => { 46 | const rows = (await pool.query(`SELECT id,fields FROM ${toPgTable(schema,table)}`)).rows; 47 | return _.map(rows, obj => _.assign({ __tableName: table }, obj)); 48 | })); 49 | return _.flatten(allObectsGrouped); 50 | } 51 | 52 | /** 53 | * Without proper metadata API we can cleanup foreign key relations in two steps: 54 | * 1. Gather ID's of all objects in database 55 | * 2. Remove all mentions of this ID's using strict equality 56 | */ 57 | async function getAllObjectsWithoutKeys(allObjects) { 58 | const allIds = _.map(allObjects, 'id'); 59 | return _.map(allObjects, (object) => cleanObjectFromFk(object, allIds)); 60 | } 61 | 62 | async function restoreSinglePlainObject(base, excludeFields, obj) { 63 | const { __tableName, fields, id } = obj; 64 | try { 65 | const newId = (await base(__tableName).create(_.omit(fields, excludeFields))).id; 66 | return [id, newId]; 67 | } catch (e) { 68 | if (e.error === 'INVALID_VALUE_FOR_COLUMN') { 69 | // TODO : remove this hack when get proper metadata 70 | const field = e.message.match(/Field (.*?) can not accept value/); 71 | console.log("Computed:" + field[1]); 72 | excludeFields.push(field[1]); 73 | return restoreSinglePlainObject(base, excludeFields, obj); 74 | } 75 | console.log(obj); 76 | console.log(e) 77 | }; 78 | } 79 | 80 | /** 81 | * Because we can't enforce Airtable to assign old ID's of the objects we should keep track of new generated id's and associate 82 | * them with old id's to be able later recreate relations 83 | */ 84 | async function restorePlainObjects(base, objectsWithoutFk) { 85 | const excludeFields = []; 86 | const oldIdToNewMapping = await Promise.all(_.map(objectsWithoutFk, _.partial(restoreSinglePlainObject, base, excludeFields))); 87 | return { oldIdToNewMapping: _.fromPairs(oldIdToNewMapping), excludeFields }; 88 | } 89 | 90 | function restoreRelations(base, allObjects, { oldIdToNewMapping, excludeFields }) { 91 | return Promise.all(_.map(allObjects, ({ __tableName, fields, id }) => base(__tableName).update(oldIdToNewMapping[id], replaceOldFKtoNewFK(_.omit(fields, excludeFields), oldIdToNewMapping)))); 92 | } 93 | 94 | /** 95 | * Sync Postgres database content to Airtable, usefull for restoring from backup after Airtable database loss. Airtable schema should be in place, only data are synced. 96 | * NOTE: without metadata API very hard to track dependencies between tables that's why restore is 2-step process: 97 | * 1. Restore objects with empty relations 98 | * 2. Restore all relations 99 | * 100 | * Also airtable have constraint for 5requests/sec and no batch insert or batch update API. 101 | * For free plan(1200 records) it can take (1200 / 5) / 60 = 4 minutes to restore full database once, and extra 4 minutes for restore foreign keys 102 | */ 103 | async function restoreAirtableFromPostgres(targetBase, apiKey, schema, tables) { 104 | try { 105 | var base = new Airtable({ apiKey }).base(targetBase); 106 | const allObjects = await getAllObjects(schema, tables); 107 | const objectsWithoutFk = await getAllObjectsWithoutKeys(allObjects); 108 | const plainObjectRestoreMetainfo = await restorePlainObjects(base, objectsWithoutFk); 109 | console.log(plainObjectRestoreMetainfo); 110 | await restoreRelations(base, allObjects, plainObjectRestoreMetainfo); 111 | } catch (e) { 112 | console.log(e); 113 | } 114 | } 115 | 116 | module.exports = restoreAirtableFromPostgres; 117 | -------------------------------------------------------------------------------- /src/sync.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const _ = require('lodash'); 3 | const Airtable = require('airtable'); 4 | const hash = require('object-hash'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | const pool = new Pool({ 9 | connectionString: process.env.DATABASE_URL 10 | }); 11 | 12 | class Syncronizer { 13 | constructor(config, changeHooks) { 14 | this.base = new Airtable({ apiKey: config.apiKey }).base(config.base); 15 | this.changeHooks = changeHooks; 16 | this.config = config; 17 | this.schema = this.config.schema; 18 | } 19 | 20 | toPgTable(table) { 21 | return this.schema + '.' + table.replace(/ /, '_'); 22 | } 23 | 24 | async createFunctions() { 25 | let functions = fs.readFileSync(path.resolve(__dirname, 'functions.sql'), 'utf8'); 26 | await pool.query(functions); 27 | } 28 | 29 | async createSchema() { 30 | await pool.query(`CREATE SCHEMA IF NOT EXISTS ${this.schema}`); 31 | } 32 | 33 | async createTable(table) { 34 | const pgTable = this.toPgTable(table); 35 | const isExists = (await pool.query(`SELECT to_regclass('${pgTable}') AS exists`)).rows[0].exists; 36 | if (!isExists) { 37 | await pool.query(`CREATE TABLE ${pgTable} (id text, fields jsonb, hash text, created_time timestamp)`); 38 | console.log(`Table ${pgTable} created`); 39 | } 40 | } 41 | 42 | runChangeHooks(entityName, currentState, eventType, entities) { 43 | const currentStateById = _.keyBy(currentState, 'id'); 44 | _.forEach(entities, (entity) => { 45 | _.forEach(this.changeHooks || [], (hook) => hook(entityName, eventType, currentStateById[entity.id] || {}, entity)); 46 | }) 47 | } 48 | 49 | syncTable(table, id) { 50 | const pgTable = this.toPgTable(table); 51 | const runChangeHooks = this.runChangeHooks.bind(this); 52 | 53 | return new Promise((resolve, reject) => { 54 | const allValues = []; 55 | const filter = id ? { filterByFormula: `RECORD_ID()='${id}'` } : {}; 56 | this.base(table).select(filter).eachPage((records, fetchNextPage) => { 57 | records.forEach(function (record) { 58 | allValues.push({ 59 | id: record.id, 60 | fields: record.fields, 61 | hash: hash(record.fields), 62 | createdTime: record._rawJson.createdTime 63 | }); 64 | }); 65 | fetchNextPage(); 66 | }, async error => { 67 | if (error) { 68 | this.syncTable(table, id); 69 | return reject(error); 70 | } 71 | 72 | const currentState = (await pool.query(`SELECT id,fields,hash FROM ${pgTable} ${id && 'WHERE id=$1'}`, _.compact([id]))).rows; 73 | const added = _.differenceBy(allValues, currentState, 'id'); 74 | const deleted = _.differenceBy(currentState, allValues, 'id'); 75 | const changed = _(allValues) 76 | .differenceBy(added, 'id') 77 | .differenceBy(deleted, 'id') 78 | .differenceBy(currentState, 'hash') 79 | .value(); 80 | 81 | const addedP = added.map((record) => pool.query(`INSERT INTO ${pgTable} VALUES ($1,$2,$3,$4)`, [record.id, record.fields, record.hash, record.createdTime])); 82 | const deletedP = deleted.map(({ id }) => pool.query(`DELETE FROM ${pgTable} WHERE id = $1`, [id])); 83 | const changedP = changed.map(record => pool.query(`UPDATE ${pgTable} SET fields=$1, hash=$2 WHERE id = $3`, [record.fields, record.hash, record.id])); 84 | 85 | await Promise.all(_.flattenDeep([addedP, deletedP, changedP])); 86 | 87 | runChangeHooks(table, currentState, 'insert', added); 88 | runChangeHooks(table, currentState, 'update', changed); 89 | runChangeHooks(table, currentState, 'delete', deleted); 90 | 91 | console.log(`${table} (Added: ${added.length}, Changed: ${changed.length}, Deleted: ${deleted.length})`); 92 | 93 | resolve(); 94 | }); 95 | }) 96 | } 97 | 98 | async startSyncronization() { 99 | for (let i in this.config.tables) { 100 | try { 101 | await this.syncTable(this.config.tables[i]); 102 | } catch (e) { 103 | console.error(e); 104 | } 105 | } 106 | if (process.env.NODE_ENV === 'production') 107 | setTimeout(this.startSyncronization.bind(this), 1000); 108 | } 109 | 110 | async setupPeriodicUpdate() { 111 | await this.createFunctions(); 112 | await this.createSchema(); 113 | await Promise.all(this.config.tables.map(this.createTable.bind(this))); 114 | 115 | await this.startSyncronization(); 116 | } 117 | } 118 | 119 | module.exports = Syncronizer; 120 | -------------------------------------------------------------------------------- /test/01-restore.js: -------------------------------------------------------------------------------- 1 | const config = require('../config/test'); 2 | const _ = require('lodash'); 3 | const restore = require('../src/restore'); 4 | const airql = require('../index'); 5 | const Airtable = require('airtable'); 6 | const { pool } = require('./utils'); 7 | const chai = require('chai'); 8 | 9 | function toPgTable(schema, table) { 10 | return schema + '.' + table.replace(/ /, '_'); 11 | } 12 | 13 | async function clearAirtableTable(schema, base, table) { 14 | const ids = (await pool.query(`select id from ${toPgTable(schema, table)}`)).rows; 15 | for (let i in ids) { 16 | await base(table).destroy(ids[i].id); 17 | } 18 | } 19 | 20 | function embedValues(allEntities, root) { 21 | let embedded = false; 22 | do { 23 | embedded = false; 24 | _.forEach( 25 | allEntities, 26 | (ent) => _.forEach(ent, (val, key) => { 27 | if (_.isArray(val)) { 28 | ent[key] = _.map(val, (v) => { 29 | // simple cycle detection - when we meet 'root' entity - stop 30 | if (allEntities[v]) { 31 | if (allEntities[v].__type !== root) { 32 | embedded = true; 33 | return allEntities[v]; 34 | } 35 | else return allEntities[v].Name; 36 | } 37 | return v; 38 | }); 39 | if (_.every(ent[key], 'Name')) 40 | ent[key] = _.sortBy(ent[key], 'Name'); 41 | else 42 | ent[key] = ent[key].sort(); 43 | } 44 | })); 45 | } while (embedded); 46 | 47 | // return only values with type 'root' 48 | return _.filter(_.values(allEntities), (v) => v.__type === root); 49 | } 50 | 51 | async function buildDeepTree(base, tables, root) { 52 | // gather all entities from all tables 53 | const allEntities = {}; 54 | for (let i in tables) { 55 | const result = (await base(tables[i]).select({}).firstPage()); 56 | const groupedResult = _.keyBy(_.map(result, (v) => _.assign(v.fields, { __type: tables[i], id: v.id })), 'id'); 57 | _.assign(allEntities, groupedResult); 58 | } 59 | 60 | // remove unnesecary fields 61 | _.forEach(allEntities, (v) => { 62 | _.unset(v, 'id'); 63 | _.unset(v, 'Created time'); 64 | _.unset(v, 'Attachements[0].thumbnails'); // it's not enough time to generate thumbnails from Airtable side 65 | _.unset(v, 'Attachements[0].id'); 66 | _.unset(v, 'Attachements[0].url'); 67 | }); 68 | 69 | return _.sortBy(embedValues(allEntities, root), 'Name'); 70 | } 71 | 72 | describe('Deep tree embed', async function () { 73 | it('embed correctly', () => { 74 | const allValues = { 75 | p1: { __type: 'Property', id: 'p1', Name: 'Prop1', landlord: ['l1'], features: ['f1', 'f2'] }, 76 | l1: { __type: 'Landlord', id: 'l1', Name: 'Land1', properties: ['p1'] }, 77 | f1: { __type: 'Feature', id: 'f1', Name: 'Feature1', properties: ['p1'] }, 78 | f2: { __type: 'Feature', id: 'f2', Name: 'Feature2', properties: ['p1'] } 79 | }; 80 | 81 | const correctTree = [ 82 | { 83 | "__type": "Property", 84 | "id": "p1", 85 | "Name": "Prop1", 86 | "landlord": [{ "__type": "Landlord", "id": "l1", "Name": "Land1", "properties": ["Prop1"] }], 87 | "features": [ 88 | { "__type": "Feature", "id": "f1", "Name": "Feature1", "properties": ["Prop1"] }, 89 | { "__type": "Feature", "id": "f2", "Name": "Feature2", "properties": ["Prop1"] } 90 | ] 91 | } 92 | ]; 93 | 94 | chai.expect(embedValues(allValues, 'Property')).to.be.deep.equal(correctTree); 95 | }); 96 | }) 97 | 98 | describe('Restore', async function () { 99 | this.timeout(20000); 100 | 101 | const tables = config.tables; 102 | const sourceDatabase = 'appOqesCBzpUDkCRa'; 103 | const destinationDatabase = 'appi7MJY9TJIqNNJj'; 104 | 105 | const apiKey = 'keymYek7PsWGf6j7i'; 106 | 107 | const commonConfig = { tables, apiKey }; 108 | 109 | const destinationAirtableApi = new Airtable({ apiKey }).base(destinationDatabase); 110 | const sourceAirtableApi = new Airtable({ apiKey }).base(sourceDatabase); 111 | 112 | before(async () => { 113 | // Sync Source Airtable to local Postgres 114 | await pool.query(`DROP SCHEMA IF EXISTS source CASCADE`); 115 | let sourceAirql = airql.airtableRestRouter(_.assign({ base: sourceDatabase, schema: 'source' }, commonConfig)).airtable; 116 | await sourceAirql.setupPeriodicUpdate(); 117 | 118 | // Clear destination database 119 | await pool.query(`DROP SCHEMA IF EXISTS target CASCADE`); 120 | let destinationAirql = airql.airtableRestRouter(_.assign({ base: destinationDatabase, schema: 'target' }, commonConfig)).airtable; 121 | await destinationAirql.setupPeriodicUpdate(); 122 | console.log('Sync done'); 123 | for (let i in tables) { 124 | await clearAirtableTable('target', destinationAirtableApi, tables[i]); 125 | } 126 | console.log('Clear done'); 127 | }); 128 | 129 | // Ensure that destination database is empty 130 | it('destination table should be empty', async () => { 131 | for (let i in tables) { 132 | const result = (await destinationAirtableApi(tables[i]).select({}).firstPage()); 133 | chai.expect(result.length).to.equal(0); 134 | } 135 | }); 136 | 137 | it('after restore content of two airtable bases should be identical', async () => { 138 | await restore(destinationDatabase, apiKey, 'source', tables); 139 | // IDEA : build deep nested graph that represent whole database, remove id field and then deeply compare graphs 140 | const dstBase = await buildDeepTree(destinationAirtableApi, tables, 'Property'); 141 | const srcBase = await buildDeepTree(sourceAirtableApi, tables, 'Property'); 142 | chai.expect(dstBase).to.be.deep.equal(srcBase); 143 | }); 144 | }) -------------------------------------------------------------------------------- /test/02-functional.js: -------------------------------------------------------------------------------- 1 | let chai = require('chai'); 2 | 3 | let { server, airtable } = require('./rest'); 4 | let { clearPostgresTable, selectAndCompareLocalAndRemote, getEntitiesAsMap } = require('./utils'); 5 | let config = require('../config/test'); 6 | 7 | function checkEqual(filter, maxRecords) { 8 | return selectAndCompareLocalAndRemote(server, `/Property?maxRecords=${maxRecords || 100}&view=Grid%20view&filterByFormula=${encodeURIComponent(filter)}&sort[0][field]=Name&sort[0][direction]=asc`); 9 | } 10 | 11 | // TODO : use Airtable.js with custom endpoint 12 | // You can use https://codepen.io/airtable/full/rLKkYB to create proper Airtable API URL 13 | describe('Properties', function () { 14 | this.timeout(5000); 15 | before(async () => { 16 | await clearPostgresTable('Property'); 17 | await airtable.setupPeriodicUpdate(); 18 | }); 19 | 20 | describe('/GET All Properties', () => { 21 | it('it should GET all the Properties', () => { 22 | return checkEqual(`AND(FIND('London', ARRAYJOIN(CityLookup, ';')))`, 3); 23 | }); 24 | }); 25 | describe('/GET All Properties with Space in Field name', () => { 26 | it('it should GET all the Properties', () => { 27 | return checkEqual(`{Single select}='yes'`); 28 | }); 29 | }); 30 | describe('/GET All Properties with complex formula', () => { 31 | it('it should GET all the Properties', () => { 32 | return checkEqual(`AND(AND(IF({Extra price(rollup)}>=5,TRUE()),IF({Extra price(rollup)}<=18, TRUE())))`); 33 | }); 34 | }); 35 | describe('/GET All Properties with access to Lookup field', () => { 36 | it('it should GET all the Properties', () => { 37 | return checkEqual(`{City name field}='London,Manchester'`); 38 | }); 39 | }); 40 | describe('/GET All Properties with access to Lookup field', () => { 41 | it('it should GET all the Properties', () => { 42 | return checkEqual(`'London,Manchester'={City name field}`); 43 | }); 44 | }); 45 | describe('/GET All Properties Greater or equals', () => { 46 | it('it should GET all the Properties', () => { 47 | return checkEqual(`{Extra price(rollup)}>=10`); 48 | }); 49 | }); 50 | describe('/GET complex AND formula', () => { 51 | it('it should get by complex AND formula', () => { 52 | return checkEqual(`{Extra price(rollup)}>=10`); 53 | }) 54 | } ); 55 | describe('/GET filter by number lookup field(Latitude)', () => { 56 | it('it should get by Latitude field', () => { 57 | return checkEqual(`{Latitude lookup}>1.00000001`); 58 | }) 59 | } ); 60 | describe('/GET filter by number lookup field(Latitude) negative', () => { 61 | it('it should get by Latitude field negative', () => { 62 | return checkEqual(`{Latitude lookup}<-1.00000000`); 63 | }) 64 | } ) 65 | describe('/POST New property', () => { 66 | it('it should Post property which should arrive both in local and remote repository', async () => { 67 | const cities = await getEntitiesAsMap('target.City_name', 'Name'); 68 | const features = await getEntitiesAsMap('target.Feature', 'Name'); 69 | let property = { 70 | fields: { 71 | "Name": "Some property" + new Date(), 72 | "City": [ 73 | cities.London 74 | ], 75 | "Features": [ 76 | features.Gym, 77 | features.Lounge 78 | ], 79 | "Date": "2018-07-11", 80 | "Single select": "yes" 81 | } 82 | }; 83 | const result = await chai.request(server).post(`/v0/${config.base}/Property`).send(property); 84 | return checkEqual(`RECORD_ID()='${result.body.id}'`); 85 | }); 86 | }); 87 | // describe('/GET/:id book', () => { 88 | // it('it should GET a book by the given id', (done) => { 89 | // let book = new Book({ title: "The Lord of the Rings", author: "J.R.R. Tolkien", year: 1954, pages: 1170 }); 90 | // book.save((err, book) => { 91 | // chai.request(server) 92 | // .get('/book/' + book.id) 93 | // .send(book) 94 | // .end((err, res) => { 95 | // res.should.have.status(200); 96 | // res.body.should.be.a('object'); 97 | // res.body.should.have.property('title'); 98 | // res.body.should.have.property('author'); 99 | // res.body.should.have.property('pages'); 100 | // res.body.should.have.property('year'); 101 | // res.body.should.have.property('_id').eql(book.id); 102 | // done(); 103 | // }); 104 | // }); 105 | 106 | // }); 107 | // }); 108 | describe('/PATCH/:id property', async () => { 109 | const newName = "The Chronicles of Narnia" + (new Date()); 110 | 111 | it('change Property name and City(CityLookup also should change)', async () => { 112 | const cities = await getEntitiesAsMap('target.City_name', 'Name'); 113 | const id = (await getEntitiesAsMap('target.Property', 'Name'))['Property #1']; 114 | 115 | await chai.request(server).patch(`/v0/${config.base}/Property/${id}`).send({ fields: { Name: newName, City: [cities.Zaporozhye] } }); 116 | const [localZp] = await checkEqual(`RECORD_ID()='${id}'`); 117 | chai.expect(localZp.body.records[0].fields.Name).to.equal(newName); 118 | chai.expect(localZp.body.records[0].fields.CityLookup[0]).to.equal('Zaporozhye'); 119 | }); 120 | 121 | it('Change City one more time to ensure that Lookup field is also changed', async () => { 122 | const cities = await getEntitiesAsMap('target.City_name', 'Name'); 123 | const id = (await getEntitiesAsMap('target.Property', 'Name'))[newName]; 124 | 125 | await chai.request(server).patch(`/v0/${config.base}/Property/${id}`).send({ fields: { City: [cities.London] } }); 126 | const [localLondon] = await checkEqual(`RECORD_ID()='${id}'`); 127 | chai.expect(localLondon.body.records[0].fields.CityLookup[0]).to.equal('London'); 128 | }); 129 | }); 130 | 131 | describe('/GET All Properties', () => { 132 | it('After all changes and updates - lists should be qeual', () => { 133 | return selectAndCompareLocalAndRemote(server, `/Property?maxRecords=100&view=Grid%20view`); 134 | }); 135 | }); 136 | 137 | // describe('/DELETE/:id book', () => { 138 | // it('it should DELETE a book given the id', (done) => { 139 | // let book = new Book({ title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778 }) 140 | // book.save((err, book) => { 141 | // chai.request(server) 142 | // .delete('/book/' + book.id) 143 | // .end((err, res) => { 144 | // res.should.have.status(200); 145 | // res.body.should.be.a('object'); 146 | // res.body.should.have.property('message').eql('Book successfully deleted!'); 147 | // res.body.result.should.have.property('ok').eql(1); 148 | // res.body.result.should.have.property('n').eql(1); 149 | // done(); 150 | // }); 151 | // }); 152 | // }); 153 | // }); 154 | }); -------------------------------------------------------------------------------- /test/03-hooks.js: -------------------------------------------------------------------------------- 1 | let chai = require('chai'); 2 | let sinon = require('sinon'); 3 | let _ = require('lodash'); 4 | let config = require('../config/test'); 5 | 6 | let { server, airtable } = require('./rest'); 7 | let { clearPostgresTable, getEntitiesAsMap } = require('./utils'); 8 | 9 | describe('Hooks', function () { 10 | this.timeout(5000); 11 | before(async () => { 12 | await clearPostgresTable('Property'); 13 | await airtable.setupPeriodicUpdate(); 14 | }); 15 | 16 | beforeEach(() => { 17 | airtable.onChangeHooks.length = 0; 18 | }) 19 | 20 | describe('/POST New property', () => { 21 | it('it should call onChange hook', async () => { 22 | const callbackSpy = sinon.spy(); 23 | airtable.onChange(callbackSpy); 24 | const cities = await getEntitiesAsMap('target.City_name', 'Name'); 25 | const features = await getEntitiesAsMap('target.Feature', 'Name'); 26 | let property = { 27 | fields: { 28 | "Name": "Some property", 29 | "City": [ 30 | cities.London 31 | ], 32 | "Features": [ 33 | features.Gym, 34 | features.Lounge 35 | ], 36 | "Date": "2018-07-11", 37 | "Single select": "yes" 38 | } 39 | }; 40 | await chai.request(server).post(`/v0/${config.base}/Property/`).send(property); 41 | chai.assert(callbackSpy.calledOnce); 42 | chai.assert(callbackSpy.calledWith('Property', 'insert', {}, sinon.match(property))); 43 | }); 44 | }); 45 | 46 | describe('/PATCH/:id property', async () => { 47 | it('hook with update should be called', async () => { 48 | const callbackSpy = sinon.spy(); 49 | airtable.onChange(callbackSpy); 50 | 51 | const id = _.entries((await getEntitiesAsMap('target.Property', 'Name')))[0][1]; 52 | 53 | const oldValue = (await chai.request(server).get(`/v0/${config.base}/Property/${id}`)).body; 54 | const newProps = { fields: { Name: ("New name " + new Date().getTime()) } }; 55 | 56 | await chai.request(server).patch(`/v0/${config.base}/Property/${id}`).send(newProps); 57 | 58 | chai.assert(callbackSpy.calledOnce); 59 | chai.assert(callbackSpy.calledWith('Property', 'update', sinon.match.has('fields',sinon.match(oldValue.fields)), sinon.match.has('fields', sinon.match(_.assign({}, oldValue.fields, newProps.fields))))); 60 | }); 61 | }); 62 | 63 | }); -------------------------------------------------------------------------------- /test/04-invalid-input.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshcodeIT/airtable-rest-graphql-postgres/b7568bfe1ceb2b35bc663c5e066ef8e24299d9af/test/04-invalid-input.js -------------------------------------------------------------------------------- /test/05-paging.js: -------------------------------------------------------------------------------- 1 | let chai = require('chai'); 2 | let _ = require('lodash'); 3 | let Airtable = require('airtable'); 4 | let config = require('../config/test'); 5 | let {getEntitiesCount} = require('./utils'); 6 | 7 | let { server } = require('./rest'); 8 | server.listen(5000); 9 | 10 | let base = new Airtable({ apiKey: config.apiKey, endpointUrl: 'http://localhost:5000' }).base(config.base); 11 | 12 | describe('Paging offset API', function () { 13 | this.timeout(5000); 14 | describe('/GET list of all properties', () => { 15 | it('it should get each page from the list', (done) => { 16 | const pages = []; 17 | base('Property').select({pageSize:3, maxRecords:100}).eachPage((records, next) => { 18 | pages.push(_.map(records, 'fields.Name')); 19 | next(); 20 | }, async () => { 21 | const dbCount = await getEntitiesCount('target.Property'); 22 | chai.expect(pages.length).to.be.greaterThan(1); 23 | chai.expect(_.every(pages, (p) => p.length<=3)).to.be.true; 24 | chai.expect(_.uniq(_.flatten(pages)).length).to.be.equal(dbCount); 25 | done(); 26 | }); 27 | }); 28 | }); 29 | }); -------------------------------------------------------------------------------- /test/rest.js: -------------------------------------------------------------------------------- 1 | let express = require('express'); 2 | let app = express(); 3 | let bodyParser = require('body-parser'); 4 | let morgan = require('morgan'); 5 | let airql = require('../index'); 6 | 7 | app.use(morgan('dev')); 8 | app.use(bodyParser.json()); 9 | app.use(bodyParser.urlencoded({ extended: true })); 10 | app.use(bodyParser.text()); 11 | app.use(bodyParser.json({ type: 'application/json' })); 12 | 13 | let {router, airtable} = airql.airtableRestRouter(require('../config/test')); 14 | 15 | app.use('/v0/appi7MJY9TJIqNNJj', router); 16 | 17 | module.exports = {server: app, airtable}; -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | let chai = require('chai'); 2 | let chaiHttp = require('chai-http'); 3 | const { Pool } = require('pg'); 4 | let config = require('../config/test'); 5 | const _ = require('lodash'); 6 | 7 | chai.use(chaiHttp); 8 | 9 | const pool = new Pool({ 10 | connectionString: process.env.DATABASE_URL 11 | }); 12 | 13 | async function clearPostgresTable(table) { 14 | await pool.query(`DELETE FROM ${config.schema}.${table}`); 15 | } 16 | 17 | async function clearAirtableBase(apiKey, baseId, entities) { 18 | const base = new Airtable({ apiKey }).base(baseId); 19 | } 20 | 21 | function selectAndCompareLocalAndRemote(server, url) { 22 | return Promise 23 | .all([ 24 | chai.request(server).get(`/v0/${config.base}/${url}`), 25 | chai.request(`https://api.airtable.com/v0/${config.base}`).get(url).set('Authorization', `Bearer ${config.apiKey}`) 26 | ]) 27 | .then(([local, airtable]) => { 28 | const nameField = 'fields.Name'; 29 | const localRecords = _.sortBy(local.body.records, nameField); 30 | const airtableRecords = _.sortBy(airtable.body.records, nameField); 31 | chai.expect(_.map(localRecords, nameField)).to.be.deep.equal(_.map(airtableRecords, nameField)); 32 | chai.expect(localRecords).to.be.deep.equal(airtableRecords); 33 | chai.expect(localRecords.length).to.not.equal(0); 34 | return [local, airtable]; 35 | }); 36 | } 37 | 38 | async function getSingleEntity(table, id) { 39 | const res = await chai.request(`https://api.airtable.com/v0/${config.base}`).get(`/${table}/${id}`).set('Authorization', `Bearer ${config.apiKey}`) 40 | return res.body; 41 | } 42 | 43 | async function getEntitiesAsMap(table, groupKey) { 44 | const result = (await pool.query(`select id,fields from ${table}`)).rows; 45 | return _.fromPairs(_.map(result, (ent) => [ent.fields[groupKey], ent.id])); 46 | } 47 | 48 | async function getEntitiesCount(table) { 49 | return parseInt((await pool.query(`select count(*) as cnt from ${table}`)).rows[0].cnt); 50 | } 51 | 52 | process.on('unhandledRejection', e => { throw e; }); 53 | 54 | module.exports = {clearAirtableBase, clearPostgresTable, selectAndCompareLocalAndRemote, getSingleEntity, pool, getEntitiesAsMap, getEntitiesCount}; --------------------------------------------------------------------------------