├── .circleci └── config.yml ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── EventStream-e2e-browser.html ├── EventStream-e2e-node.js ├── LICENSE ├── README.md ├── RELEASE.md ├── bower.json ├── dist ├── particle.min.js └── particle.min.js.map ├── docs └── api.md ├── examples └── login │ └── login.html ├── fs.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── Agent.js ├── Client.js ├── Defaults.js ├── EventStream.js ├── Library.js └── Particle.js ├── test ├── .eslintrc ├── Agent.integration.js ├── Agent.spec.js ├── Client.spec.js ├── Defaults.spec.js ├── EventStream.feature ├── EventStream.spec.js ├── FakeAgent.js ├── Library.spec.js ├── Particle.integration.js ├── Particle.spec.js ├── fixtures │ ├── index.js │ ├── libraries.json │ ├── library.json │ └── libraryVersions.json ├── out.tmp ├── support │ └── FixtureHttpServer.js └── test-setup.js ├── tsconfig.json └── webpack.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | browser-tools: circleci/browser-tools@1.4.8 5 | 6 | jobs: 7 | run-tests: 8 | parameters: 9 | node-version: 10 | type: string 11 | docker: 12 | - image: cimg/node:<< parameters.node-version >>-browsers # Primary execution image 13 | auth: 14 | username: $DOCKERHUB_USERNAME 15 | password: $DOCKERHUB_PASSWORD 16 | steps: 17 | - checkout 18 | - run: 19 | name: NPM install 20 | command: npm ci 21 | - run: 22 | name: Run tests with coverage 23 | command: npm run test:ci 24 | - when: 25 | condition: 26 | equal: ["16.20.0", << parameters.node-version >>] 27 | steps: 28 | - browser-tools/install-browser-tools 29 | - run: 30 | name: Run tests with browser 31 | command: npm run test:browser 32 | publish-npm: 33 | docker: 34 | - image: cimg/node:16.20.0 # Primary execution image 35 | auth: 36 | username: $DOCKERHUB_USERNAME 37 | password: $DOCKERHUB_PASSWORD 38 | steps: 39 | - checkout 40 | - run: 41 | name: NPM install 42 | command: npm ci 43 | - run: 44 | name: Authenticate with NPM 45 | command: npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" 46 | - run: 47 | name: Publish package 48 | command: | 49 | # Publish as beta for pre-release tags like v1.2.3-pre.1 50 | [[ $CIRCLE_TAG =~ ^v.*- ]] && NPM_TAG=--tag=beta 51 | npm publish $NPM_TAG 52 | 53 | workflows: 54 | version: 2 55 | test-and-publish: 56 | jobs: 57 | - run-tests: 58 | context: 59 | - particle-ci-private 60 | matrix: 61 | parameters: 62 | node-version: ["12.22.12", "14.19.2", "16.20.0"] 63 | # run tests for all branches and tags 64 | filters: 65 | tags: 66 | only: /^v.*/ 67 | branches: 68 | only: /.*/ 69 | - publish-npm: 70 | requires: 71 | - run-tests 72 | context: 73 | - particle-ci-private 74 | # publish for tags only 75 | filters: 76 | tags: 77 | only: /^v.*/ 78 | branches: 79 | ignore: /.*/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint-config-particle'], 3 | parserOptions: { 4 | sourceType: 'module' 5 | }, 6 | env: { 7 | browser: true, 8 | commonjs: true, 9 | es6: true, 10 | node: true, 11 | mocha: true, 12 | worker: true, 13 | serviceworker: true 14 | }, 15 | rules: { 16 | 'no-prototype-builtins': 'off', 17 | 'no-redeclare': 'off', 18 | camelcase: ['error', { 19 | properties: 'never', 20 | allow: ['redirect_uri'] 21 | }], 22 | 'no-mixed-spaces-and-tabs': 'error', 23 | indent: ['error', 4] 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | pids 4 | *.pid 5 | *.seed 6 | lib-cov 7 | coverage 8 | .lock-wscript 9 | build/Release 10 | node_modules 11 | lib/ 12 | /.idea 13 | *.tgz 14 | .vscode 15 | tmp -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 11.1.2 - 11 December 2024 4 | * Add context docs for x-particle-tool and x-particle-project headers 5 | 6 | ## 11.1.1 - 5 November 2024 7 | * Workaround for Firefox failing to open multiple event streams 8 | 9 | ## 11.1.0 - 3 October 2024 10 | * Re-add `deleteAccessToken` method, but with basic auth removed 11 | 12 | ## 11.0.0 - 1 October 2024 13 | * Remove `listAccessTokens` and `deleteAccessToken` methods that relied on HTTP basic auth 14 | * Update EventStream to send access_token in the Authorization header instead. No functional change 15 | 16 | ## 10.6.0 - 21 August 2024 17 | * Add DeviceOS versions endpoints 18 | 19 | ## 10.5.1 - 21 June 2024 20 | * Don't add an empty query string to the URL 21 | 22 | ## 10.5.0 - 14 June 2024 23 | * Add `unprotectDevice` 24 | * Remove `changeProduct` 25 | 26 | ## 10.4.3 - 25 March 2024 27 | * Add `utm` paramater to `createUser` 28 | 29 | ## 10.4.2 - 3 January 2024 30 | * Add `setMode` arg to `setLedgerInstance` 31 | * Fix a few incorrect logic function JSDocs 32 | 33 | ## 10.4.1 - 2 January 2024 34 | 35 | * Fix `setLedgerInstance` taking wrong argument and passing a bad request body to the API 36 | 37 | ## 10.4.0 - 19 December 2023 38 | 39 | * Add `listLedgerInstanceVersions` function 40 | * Add `getLedgerInstanceVersion` function 41 | 42 | ## 10.3.1 - 6 December 2023 43 | 44 | * Add `todayStats` query option to listLogicFunctions 45 | * Add `scope`, `archived`, `page`, and `perPage` query options to listLedgers 46 | * Add `page` and `perPage` query options to listLedgerInstances 47 | * Add JSDocs for constructor 48 | * Fix JSDocs across most functions in the library to be more accurate 49 | 50 | ## 10.3.0 - 7 November 2023 51 | 52 | * Add support for sandbox accounts on all logic/ledger APIs by allowing the omission of the `org` property 53 | * Add `executeLogic` function for testing logic functions before deploying them 54 | 55 | ## 10.2.0 - 6 October 2023 56 | 57 | * Migrate LogicBlocks methods to LogicFunctions, LogicTriggers, and LogicRuns 58 | 59 | ## 10.1.0 - 8 Sept 2023 60 | 61 | * Use wepback to bundle for browser use 62 | * Remove babel, browserify and their related dependencies 63 | * Adds support for checkJS loose type/docs validations 64 | 65 | ## 10.0.0 - 8 Sept 2023 66 | 67 | * Change library to handle requests from `superagent` to `fetch`/`node-fetch` 68 | 69 | ## 9.4.1 - 17 June 2022 70 | 71 | * Fixes incompatible versions of `eslint`, `chai`, `sinon-chai` and `chai-as-promised`. 72 | 73 | ## 9.4.0 - 14 June 2022 74 | 75 | * Adds `.setDefaultAuth(auth)` so token authenticated methods don't need to pass their own auth token. 76 | * Adds support for `auth` option in Particle constructor, calls `.setDefaultAuth()` if provided 77 | * Fixes bug where `.setBaseUrl(baseUrl)` was not honored for `.getEventStream()` method (sc-105035) 78 | 79 | ## 9.3.0 - 8 June 2022 80 | * Adds `.setBaseUrl(baseUrl)` to override backend api endpoint 81 | 82 | ## 9.2.0 - 30 May 2022 83 | * Move to `node@16` and `npm@8` for local development 84 | 85 | ## 9.1.2 - 9 December 2021 86 | * Fix library download 87 | 88 | ## 9.1.1 - 7 December 2021 89 | * Use unforked copy of `stream-http` dependency 90 | 91 | ## 9.1.0 - 8 December 2020 92 | * `.listAccessTokens()` accepts `otp` option to support users with MFA enabled 93 | 94 | ## 9.0.2 - 28 July 2020 95 | * Add `.deleteActiveAccessTokens()` method 96 | * Add `invalidateTokens` arguments to `.confirmMfa()` and `.changeUsername()` methods 97 | 98 | ## 9.0.1 - 1 June 2020 99 | * Add `.getProductDeviceConfiguration()` and `.getProductDeviceConfigurationSchema()` methods 100 | 101 | ## 9.0.0 - 20 May 2020 102 | * Add support for configuration and location services 103 | * All top-level api methods optionally accept a `headers` option object 104 | * Breaking: Base http methods (`.get()`, `.put()`, etc) on agent and particle classes accept options object vs. positional arguments ([see here](https://github.com/particle-iot/particle-api-js/pull/115/commits/c209a43ebcda53b9dc6857e1b54228906f506feb)) 105 | * Breaking: `.downloadFile()` method uses `uri` option (vs. `url`) ([docs](https://github.com/particle-iot/particle-api-js/blob/master/docs/api.md#downloadfile)) 106 | * Breaking: Refactored options object for the `.createWebhook()` method - hook-related options are now passed in via `options.hook` ([docs](https://github.com/particle-iot/particle-api-js/blob/master/docs/api.md#createwebhook)) 107 | 108 | ## 8.4.0 - 28 April 2020 109 | * Allow invalidating access tokens when changing password 110 | 111 | ## 8.3.0 - 11 February 2020 112 | * Add delete user 113 | 114 | ## 8.2.1 - 4 February 2020 115 | * fix file download methods `.downloadFile()`, `.downloadFirmwareBinary()`, and `.downloadProductFirmware()` [PR #112](https://github.com/particle-iot/particle-api-js/pull/112) 116 | 117 | ## 8.2.0 - 28 January 2020 118 | * `.addDeviceToProduct()` accepts `file` option to facillitate bulk importing of devices [PR #109](https://github.com/particle-iot/particle-api-js/pull/109) 119 | 120 | ## 8.1.0 - 24 January 2020 121 | * Add support for `groups` query parameter when listing product devices via `.listDevices()` [PR #108](https://github.com/particle-iot/particle-api-js/pull/108) 122 | * Update `eslint` and related configuration [PR #107](https://github.com/particle-iot/particle-api-js/pull/107) 123 | 124 | ## 8.0.1 - 2 December 2019 125 | * Update to latest superagent to fix deprecation warnings in Node v12 126 | 127 | ## 8.0.0 - 30 July 2019 128 | 129 | * EventStream returned by getEventStream handles errors better [PR #99](https://github.com/particle-iot/particle-api-js/pull/99). 130 | **Breaking changes for EventStream:** 131 | - Only emits a single event named 'event' for each Particle event received instead of 2 events, one named 'event' and another named after the Particle event name. This behavior caused EventStream to disconnects if a Particle event named 'error' was published. 132 | - Does not emit the 'error' event when a network error happens. Instead it emits 'disconnect' and automatically reconnects. 133 | 134 | ## 7.4.1 - 6 May 2019 135 | * Do not require network ID to remove a device from its network [PR #103](https://github.com/particle-iot/particle-api-js/pull/103) 136 | 137 | ## 7.4.0 - 27 Feb 2019 138 | * Add support for mesh network management [PR #98](https://github.com/particle-iot/particle-api-js/pull/98) 139 | 140 | ## 7.3.0 - 10 Jan 2019 141 | * Support flashing product devices [PR #97](https://github.com/particle-iot/particle-api-js/pull/97) 142 | 143 | ## 7.2.3 - 4 Aug 2018 144 | * Add sendOtp method to allow users enrolled in MFA/Two-Step Auth to login [PR #92](https://github.com/particle-iot/particle-api-js/pull/92) 145 | 146 | ## 7.2.2 - 23 Jul 2018 147 | * Fix npm api key for publishing to registry 148 | 149 | ## 7.2.1 - 23 Jul 2018 150 | * Support enrolling user in MFA/Two-step authentication 151 | 152 | ## 7.2.0 - 22 Mar 2018 153 | * Support changing user's username(i.e., email) and password [PR #84](https://github.com/particle-iot/particle-api-js/pull/84) 154 | 155 | ## 7.1.1 - 13 Feb 2018 156 | * Fix country parameter for activate sim [PR #81](https://github.com/particle-iot/particle-api-js/pull/81) 157 | 158 | ## 7.1.0 - 17 Jan 2018 159 | 160 | * Update jsDelivr link [PR #66](https://github.com/particle-iot/particle-api-js/pull/66). Thanks @LukasDrgon! 161 | * Stop auto reconnecting when event stream is intentionally disconnected [PR #69](https://github.com/particle-iot/particle-api-js/pull/69). Thanks @spacetc62! 162 | * Add createCustomer [PR #78](https://github.com/particle-iot/particle-api-js/pull/78). Thanks @monkeytronics! 163 | * Fix event stream exception when it is an HTML response [PR #64](https://github.com/particle-iot/particle-api-js/pull/64). Thanks @spacetc62! 164 | * Update links after GitHub organization rename to `particle-iot` [PR #79](https://github.com/particle-iot/particle-api-js/pull/79) 165 | 166 | ## 7.0.1 - 16 Nov 2017 167 | * Add loginAsClientOwner method 168 | 169 | ## 7.0.0 - 7 Nov 2017 170 | * Update to latest superagent with support for nested directory. **Drops support for Node versions earlier than 4.** 171 | * Add serial number endpoint 172 | 173 | ## 6.6.2 - 15 Sep 2017 174 | * Fix nested directories bug 175 | 176 | ## 6.6.1 - 14 Sep 2017 177 | * Update form-data to v1.0.0-relativepath.2 178 | 179 | ## 6.6.0 - 12 Sep 2017 180 | 181 | * Add support for deleting current token 182 | 183 | ## 6.5.0 - 02 May 2017 184 | 185 | * Add support for all product API endpoints. 186 | * Add support for sending additional context with each call. 187 | 188 | ## 6.4.3 - 15 Feb 2017 189 | 190 | * Create a wrapper for `listBuildTargets` in `Client.js`. 191 | * Marked `compileCode`, `signalDevice`, `listDevices` and `listBuildTargets` as deprecated. Those methods will be removed in 6.5 192 | 193 | ## 6.4.2 - 05 Jan 2017 194 | 195 | * Create a wrapper for `listDevices` in `Client.js`. 196 | 197 | ## 6.4.1 - 15 Dec 2016 198 | 199 | * Add scopes to library listing 200 | 201 | ## 6.4.0 - 09 Nov 2016 202 | 203 | * Create a wrapper for `signalDevice` in `Client.js`. 204 | 205 | ## 6.3.0 - 31 Oct 2016 206 | 207 | * Add support for account verification endpoint via verifyUser function 208 | * Change account_info input parameter in createUser and setUserInfo to be camel case - accountInfo 209 | 210 | ## 6.2.0 - 19 Oct 2016 211 | 212 | * Add support for account information fields in createUser and setUserInfo 213 | * Add "shortErrorDescription" in response body to contain English description only 214 | 215 | ## 6.1.0 - 19 Oct 2016 216 | 217 | * Add library publish 218 | 219 | ## 6.0.8 - 17 Oct 2016 220 | 221 | * Rename library publish to library contribute 222 | 223 | ## 6.0.7 - 29 Sept 2016 224 | 225 | * Add library versions endpoint 226 | 227 | ## 6.0.6 - 19 Sept 2016 228 | 229 | * Add library delete 230 | 231 | ## 6.0.5 - 8 Sept 2016 232 | 233 | * Add library publish 234 | 235 | ## 6.0.4 - 30 Aug 2016 236 | 237 | * Use only HTTP dependencies to be able to install on computers without git 238 | 239 | ## 6.0.3 - 25 Aug 2016 240 | 241 | * Support nested directories when compiling sources 242 | 243 | ## 6.0.2 - 23 Aug 2016 244 | 245 | * Add compile code to client 246 | 247 | ## 6.0.1 - 22 Aug 2016 248 | 249 | * Fix the login method content type 250 | 251 | ## 6.0.0 - 17 Aug 2016 252 | 253 | * Add libraries endpoints 254 | * Add stateful client 255 | * Add object interface for libraries 256 | 257 | ## 5.3.1 - 2 Aug 2016 258 | 259 | * Handle empty event names in the event stream. 260 | 261 | ## 5.3.0 - 8 June 2016 262 | 263 | * Add details to README 264 | * Adding responseTemplate and responseTopic to webhook creation. Thanks @acasas! [#20](https://github.com/particle-iot/particle-api-js/pull/20) 265 | * Add password reset route [#27](https://github.com/particle-iot/particle-api-js/pull/27) 266 | * Make event stream compatible with new product routes [#28](https://github.com/particle-iot/particle-api-js/pull/28) 267 | 268 | ## 5.2.7 - 2 May 2016 269 | 270 | * Fix files parameter default name to be `file` and not `file1`. 271 | 272 | ## 5.2.6 - 25 Mar 2016 273 | 274 | * Don't double publish event stream events if the event is named `event`. 275 | 276 | ## 5.2.5 - 21 Mar 2016 277 | 278 | * Handle `JSON.parse` exceptions when parsing event stream 279 | 280 | ## 5.2.4 - 21 Mar 2016 281 | 282 | * `flashDevice` `latest` also needs to be a string, not a boolean. [#12](https://github.com/particle-iot/particle-api-js/issues/12) 283 | 284 | ## 5.2.3 - 11 Mar 2016 285 | 286 | * Remove setting of `User-Agent` header because that is not allowed in browsers. [#10](https://github.com/particle-iot/particle-api-js/issues/10) 287 | 288 | ## 5.2.2 - 3 Mar 2016 289 | 290 | * Fix named event streams by encoding event name. 291 | * Move access token to query string to eliminate preflight CORS request. 292 | * Use fork of `stream-http` that prevents usage of `fetch` because it does not abort. 293 | * Use correct streaming mode of `stream-http`. 294 | 295 | ## 5.2.1 - 3 Mar 2016 296 | 297 | * Improve cleanup on `abort`. 298 | 299 | ## 5.2.0 - 3 Mar 2016 300 | 301 | * Add support for organization and product slugs to `getEventStream`. 302 | 303 | ## 5.1.1 - 26 Feb 2016 304 | 305 | * `JSON.parse` HTTP response body for `getEventStream` error case. 306 | 307 | ## 5.1.0 - 26 Feb 2016 308 | 309 | * Fix event stream. [#8](https://github.com/particle-iot/particle-api-js/issues/8) 310 | * Add `downloadFirmwareBinary` 311 | * Add ability to intercept requests for debugging 312 | * Use library version for User-Agent 313 | * Allow request transfer for `claimDevice` 314 | * `signalDevice` needs to use strings, not numbers. 315 | * `compileCode` `latest` should be a string, not a boolean. 316 | 317 | ## 5.0.2 - 24 Feb 2016 318 | 319 | * Remove trailing slash from `baseUrl`. [#7](https://github.com/particle-iot/particle-api-js/issues/7) 320 | 321 | ## 5.0.1 - 18 Feb 2016 322 | 323 | * Remove need for `require('particle-api-js').default` in CommonJS usage. It is now just `require('particle-api-js')`. 324 | 325 | ## 5.0.0 - 18 Feb 2016 326 | 327 | * Removed need for `babel-runtime`. 328 | * Add `flashDevice`, `compileCode`, and `listAccessTokens`. 329 | * Add missing options to `createWebhook`. 330 | * Remove `downloadFirmwareBinary`. 331 | 332 | ## 4.2.1 - 8 Feb 2016 333 | 334 | * Update contributors. 335 | 336 | ## 4.2.0 - 8 Feb 2016 337 | 338 | * Add `downloadFirmwareBinary`. 339 | 340 | ## 4.1.0 - 14 Jan 2016 341 | 342 | * Add `validatePromoCode`. 343 | * `activateSIM` now requires `promo_code` and `action`. 344 | 345 | ## 4.0.2 - 16 Nov 2015 346 | 347 | * Fix old `code` reference. 348 | 349 | ## 4.0.1 - 16 Nov 2015 350 | 351 | * Change `code` to `statusCode` in rejection. 352 | 353 | ## 4.0.0 - 16 Nov 2015 354 | 355 | * Add `statusCode` to Promise fulfillment. 356 | 357 | ## 3.0.3 - 6 Nov 2015 358 | 359 | * Add `listBuildTargets`. 360 | 361 | ## 3.0.2 - 5 Nov 2015 362 | 363 | * Add `countryCode` to `activateSIM`. 364 | 365 | ## 3.0.1 - 26 Oct 2015 366 | 367 | * Fix `activateSIM`. 368 | 369 | ## 3.0.0 - 26 Oct 2015 370 | 371 | * Replace `request` with `superagent`. 372 | * Add `iccid` to `getClaimCode`. 373 | * Only use form encoding on `login` and `signup`. 374 | 375 | ## 2.0.1 - 23 Oct 2015 376 | 377 | * Removed browser entry in package.json. This makes it possible to bundle the module with other apps that use browserify without causing relative pathing issues. 378 | 379 | ## 2.0.0 - 20 Oct 2015 380 | 381 | * Improved error handling and reporting. Network errors and HTTP errors now both return `code` property that can be more easily used to programmatically detect error types. 382 | 383 | ## 1.0.1 - 24 Sep 2015 384 | 385 | ## 1.0.0 - 24 Sep 2015 386 | -------------------------------------------------------------------------------- /EventStream-e2e-browser.html: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 |

Open the Javascript Console

16 | 17 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /EventStream-e2e-node.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | End-to-end test program for the event stream with Node 4 | 5 | Steps: 6 | - PARTICLE_API_TOKEN= node EventStream-e2e-node.js 7 | - Follow the scenarios in EventStream.feature 8 | 9 | */ 10 | 11 | const Particle = require('./src/Particle'); 12 | const baseUrl = process.env.PARTICLE_API_BASE_URL || 'http://localhost:9090'; 13 | const auth = process.env.PARTICLE_API_TOKEN; 14 | const particle = new Particle({ baseUrl }); 15 | 16 | 17 | /* eslint-disable no-console */ 18 | particle.getEventStream({ deviceId: 'mine', auth }) 19 | .then(stream => { 20 | console.log('event stream connected'); 21 | 22 | ['event', 'error', 'disconnect', 'reconnect', 'reconnect-success', 'reconnect-error'] 23 | .forEach(eventName => { 24 | stream.on(eventName, (arg) => { 25 | console.log(eventName, arg); 26 | }); 27 | }); 28 | }) 29 | .catch((err) => { 30 | console.error(err); 31 | process.exit(1); 32 | }); 33 | /* eslint-enable no-console */ 34 | 35 | -------------------------------------------------------------------------------- /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 2014 Spark IO 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 | # particle-api-js 2 | JS Library for the Particle Cloud API for Node.js and the browser 3 | 4 | [![Build Status](https://circleci.com/gh/particle-iot/particle-api-js.svg?style=shield)](https://app.circleci.com/pipelines/github/particle-iot/particle-api-js) 5 | 6 | [Installation](#installation) | [Development](#development) | [Conventions](#conventions) | [Docs](#docs--resources) | [Examples](#examples) | [Building](#building) | [Releasing](#releasing) | [License](#license) 7 | 8 | ## Installation 9 | 10 | `particle-api-js` is available from `npm` to use in Node.js, `bower` or jsDelivr CDN for use in the browser. 11 | 12 | #### Npm 13 | ``` 14 | $ npm install particle-api-js 15 | ``` 16 | 17 | #### Bower 18 | ``` 19 | $ bower install particle-api-js 20 | ``` 21 | 22 | #### jsDelivr CDN 23 | ```html 24 | 26 | ``` 27 | 28 | ## Development 29 | 30 | 31 | All essential commands are available at the root via `npm run 7 | 16 | 17 | -------------------------------------------------------------------------------- /fs.js: -------------------------------------------------------------------------------- 1 | // In Node, exports the fs module. In the browser, exports undefined due to "./fs": false entry in package.json 2 | module.exports = require('fs'); 3 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Jul 20 2016 12:00:09 GMT-0400 (EDT) 3 | const webpackConf = require('./webpack.config.js'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = function karmaCfg(config){ 7 | config.set({ 8 | // base path that will be used to resolve all patterns (eg. files, exclude) 9 | basePath: '', 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['webpack', 'mocha', 'chai'], 14 | 15 | // list of files / patterns to load in the browser 16 | files: [ 17 | 'dist/particle.min.js', 18 | 'test/*.spec.js', 19 | 'test/*.integration.js' 20 | ], 21 | 22 | // list of files to exclude 23 | exclude: [ 24 | ], 25 | 26 | // preprocess matching files before serving them to the browser 27 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 28 | preprocessors: { 29 | 'src/**/*.js': ['webpack'], 30 | 'test/**/*.js': ['webpack'] 31 | }, 32 | 33 | // Transform test files to a single browser consumable file 34 | webpack: { 35 | mode: 'development', 36 | target: 'web', 37 | devtool: 'inline-source-map', 38 | output: webpackConf.output, 39 | externals: webpackConf.externals, 40 | resolve: webpackConf.resolve, 41 | plugins: [ 42 | new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), 43 | new webpack.EnvironmentPlugin({ 44 | SKIP_AGENT_TEST: process.env.SKIP_AGENT_TEST || false 45 | }) 46 | ] 47 | }, 48 | 49 | // test results reporter to use 50 | // possible values: 'dots', 'progress' 51 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 52 | reporters: ['progress', 'coverage'], 53 | 54 | // web server port 55 | port: 9876, 56 | 57 | // enable / disable colors in the output (reporters and logs) 58 | colors: true, 59 | 60 | // level of logging 61 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 62 | logLevel: config.LOG_INFO, 63 | 64 | // enable / disable watching file and executing tests whenever any file changes 65 | autoWatch: true, 66 | 67 | // start these browsers 68 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 69 | browsers: ['Firefox'], 70 | 71 | // Continuous Integration mode 72 | // if true, Karma captures browsers, runs the tests and exits 73 | singleRun: false, 74 | 75 | // Concurrency level 76 | // how many browser should be started simultaneous 77 | concurrency: Infinity 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "particle-api-js", 3 | "version": "11.1.2", 4 | "description": "Particle API Client", 5 | "main": "src/Particle.js", 6 | "scripts": { 7 | "prepublish": "npm run lint && npm run build", 8 | "test": "npm run lint && npm run typecheck && npm run test:unit", 9 | "test:ci": "npm run lint && npm run test:unit -- --forbid-only && npm run coverage", 10 | "test:unit": "mocha test/ -R spec", 11 | "test:unit:silent": "npm run test:unit > tmp/test-unit-log.txt 2>&1", 12 | "test:browser": "karma start --single-run", 13 | "test:watch": "npm run test:unit -- --watch", 14 | "typecheck": "tsc --noEmit", 15 | "coverage": "nyc --reporter=text --include='src/**/*.js' --temp-dir=./tmp/ --check-coverage --lines 91 npm run test:unit:silent", 16 | "lint": "eslint . --ext .js --format unix --ignore-path .gitignore --ignore-pattern \"dist/*\"", 17 | "lint:fix": "npm run lint -- --fix", 18 | "docs": "documentation build src/Particle.js --shallow -g -f md -o docs/api.md", 19 | "build": "webpack --env mode=production", 20 | "build-nomin": "webpack --env mode=development", 21 | "preversion": "npm run test && npm run prepublish", 22 | "reinstall": "rm -rf ./node_modules && npm i", 23 | "version": "npm run build && npm run docs && npm run update-changelog && git add dist/* docs/*", 24 | "update-changelog": "VERSION=`node -p -e \"require('./package.json').version\"` bash -c 'read -p \"Update CHANGELOG.md for version $VERSION and press ENTER when done.\"' && git add CHANGELOG.md" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/particle-iot/particle-api-js" 29 | }, 30 | "author": "Julien Vanier ", 31 | "contributors": [ 32 | "Ido Kleinman", 33 | "Bryce Kahle", 34 | "Justin Debbink", 35 | "Matthew McGowan", 36 | "Julien Vanier", 37 | "Wojtek Siudzinski", 38 | "Emily Rose" 39 | ], 40 | "keywords": [ 41 | "particle", 42 | "library", 43 | "spark", 44 | "api" 45 | ], 46 | "license": "Apache-2.0", 47 | "devDependencies": { 48 | "@types/node": "^20.5.9", 49 | "buffer": "^6.0.3", 50 | "chai": "^4.3.6", 51 | "chai-as-promised": "^7.1.1", 52 | "documentation": "^4.0.0-rc.1", 53 | "eslint": "^8.17.0", 54 | "eslint-config-particle": "^2.2.1", 55 | "events": "^3.3.0", 56 | "karma": "^1.1.1", 57 | "karma-chai": "^0.1.0", 58 | "karma-cli": "^1.0.1", 59 | "karma-coverage": "^1.1.0", 60 | "karma-firefox-launcher": "^1.0.0", 61 | "karma-mocha": "^1.1.1", 62 | "karma-webpack": "^5.0.0", 63 | "mocha": "^2.5.1", 64 | "nyc": "^15.1.0", 65 | "process": "^0.11.10", 66 | "should": "^9.0.0", 67 | "sinon": "^7.2.5", 68 | "sinon-chai": "^3.7.0", 69 | "terser-webpack-plugin": "^5.3.9", 70 | "typescript": "^5.2.2", 71 | "url": "^0.11.3", 72 | "webpack": "^5.88.2", 73 | "webpack-cli": "^5.1.4" 74 | }, 75 | "dependencies": { 76 | "form-data": "^4.0.0", 77 | "node-fetch": "^2.7.0", 78 | "qs": "^6.11.2", 79 | "stream-http": "^3.2.0" 80 | }, 81 | "browser": { 82 | "./fs": false, 83 | "http": "stream-http", 84 | "https": "stream-http" 85 | }, 86 | "engines": { 87 | "node": ">=12.x", 88 | "npm": "8.x" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Agent.js: -------------------------------------------------------------------------------- 1 | /* 2 | ****************************************************************************** 3 | Copyright (c) 2016 Particle Industries, Inc. All rights reserved. 4 | 5 | This program is free software; you can redistribute it and/or 6 | modify it under the terms of the GNU Lesser General Public 7 | License as published by the Free Software Foundation, either 8 | version 3 of the License, or (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | Lesser General Public License for more details. 14 | 15 | You should have received a copy of the GNU Lesser General Public 16 | License along with this program; if not, see . 17 | ****************************************************************************** 18 | */ 19 | 20 | const fetch = require('node-fetch'); 21 | const FormData = require('form-data'); 22 | const qs = require('qs'); 23 | const fs = require('../fs'); 24 | const packageJson = require('../package.json'); 25 | 26 | /** 27 | * The object returned for a basic request 28 | * @typedef {object} JSONResponse 29 | * @property {number} statusCode The HTTP response status 30 | * @property {object} body The endpoint's response parsed as a JSON 31 | */ 32 | 33 | /** 34 | * The possible response from an API request 35 | * @typedef {JSONResponse | Buffer | ArrayBuffer} RequestResponse The type is based on 36 | * the request config and whether is on browser or node 37 | */ 38 | 39 | /** 40 | * The error object generated in case of a failed request 41 | * @typedef {object} RequestError 42 | * @property {number} statusCode The HTTP response status 43 | * @property {string} errorDescription Details on what caused the failed request 44 | * @property {string} shortErrorDescription Summarized version of the fail reason 45 | * @property {object} body The response object from the request 46 | * @property {object} error The error object from the request 47 | */ 48 | 49 | class Agent { 50 | constructor(baseUrl){ 51 | this.setBaseUrl(baseUrl); 52 | } 53 | 54 | setBaseUrl(baseUrl) { 55 | this.baseUrl = baseUrl; 56 | } 57 | 58 | /** 59 | * Make a GET request 60 | * @param {object} params Configurations to customize the request 61 | * @param {string} params.uri The URI to request 62 | * @param {string} [params.auth] Authorization token to use 63 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 64 | * @param {object} [params.query] Key/Value pairs of query params 65 | * @param {object} [params.context] The invocation context, describing the tool and project 66 | * @returns {Promise} A promise that resolves with either the requested data or an error object 67 | */ 68 | get({ uri, auth, headers, query, context }) { 69 | return this.request({ uri, method: 'get', auth, headers, query, context }); 70 | } 71 | 72 | /** 73 | * Make a HEAD request 74 | * @param {object} params Configurations to customize the request 75 | * @param {string} params.uri The URI to request 76 | * @param {string} [params.auth] Authorization token to use 77 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 78 | * @param {object} [params.query] Key/Value pairs of query params 79 | * @param {object} [params.context] The invocation context, describing the tool and project 80 | * @returns {Promise} A promise that resolves with either the requested data or an error object 81 | */ 82 | head({ uri, auth, headers, query, context }) { 83 | return this.request({ uri, method: 'head', auth, headers, query, context }); 84 | } 85 | 86 | /** 87 | * Make a POST request 88 | * @param {object} params Configurations to customize the request 89 | * @param {string} params.uri The URI to request 90 | * @param {string} [params.auth] Authorization token to use 91 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 92 | * @param {object} [params.data] Request body 93 | * @param {object} [params.context] The invocation context, describing the tool and project 94 | * @returns {Promise} A promise that resolves with either the requested data or an error object 95 | */ 96 | post({ uri, headers, data, auth, context }) { 97 | return this.request({ uri, method: 'post', auth, headers, data, context }); 98 | } 99 | 100 | /** 101 | * Make a PUT request 102 | * @param {object} params Configurations to customize the request 103 | * @param {string} params.uri The URI to request 104 | * @param {string} [params.auth] Authorization token to use 105 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 106 | * @param {object} [params.data] Request body 107 | * @param {object} [params.query] Key/Value pairs of query params or a correctly formatted string 108 | * @param {object} [params.context] The invocation context, describing the tool and project 109 | * @returns {Promise} A promise that resolves with either the requested data or an error object 110 | */ 111 | put({ uri, auth, headers, data, query, context }) { 112 | return this.request({ uri, method: 'put', auth, headers, data, query, context }); 113 | } 114 | 115 | /** 116 | * Make a DELETE request 117 | * @param {object} params Configurations to customize the request 118 | * @param {string} params.uri The URI to request 119 | * @param {string} [params.auth] Authorization token to use 120 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 121 | * @param {object} [params.data] Request body 122 | * @param {object} [params.context] The invocation context, describing the tool and project 123 | * @returns {Promise} A promise that resolves with either the requested data or an error object 124 | */ 125 | delete({ uri, auth, headers, data, context }) { 126 | return this.request({ uri, method: 'delete', auth, headers, data, context }); 127 | } 128 | 129 | /** 130 | * 131 | * @param {object} config An obj with all the possible request configurations 132 | * @param {string} config.uri The URI to request 133 | * @param {string} config.method The method used to request the URI, should be in uppercase. 134 | * @param {object} [config.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 135 | * @param {object} [config.data] Arbitrary data to send as the body. 136 | * @param {string} [config.auth] Authorization 137 | * @param {object} [config.query] Query parameters 138 | * @param {object} [config.form] Form fields 139 | * @param {object} [config.files] Array of file names and file content 140 | * @param {object} [config.context] The invocation context, describing the tool and project. 141 | * @param {boolean} [config.isBuffer=false] Indicate if the response should be treated as Buffer instead of JSON 142 | * @returns {Promise} A promise that resolves with either the requested data or an error object 143 | */ 144 | request({ 145 | uri, 146 | method, 147 | headers = undefined, 148 | data = undefined, 149 | auth, 150 | query = undefined, 151 | form = undefined, 152 | files = undefined, 153 | context = undefined, 154 | isBuffer = false 155 | }){ 156 | const requestFiles = this._sanitizeFiles(files); 157 | const requestParams = this._buildRequest({ uri, method, headers, data, auth, query, form, context, files: requestFiles }); 158 | return this._promiseResponse(requestParams, isBuffer); 159 | } 160 | 161 | /** 162 | * Promises to send the request and retrieve the response. 163 | * @param {[string, object]} requestParams First argument is the URI to request, the second one are the options. 164 | * @param {boolean} isBuffer Indicate if the response body should be returned as a Buffer (Node) / ArrayBuffer (browser) instead of JSON 165 | * @param {function} [makerequest=fetch] The fetch function to use. Override for testing. 166 | * @returns {Promise} A promise that resolves with either the requested data or an error object 167 | * @private 168 | */ 169 | _promiseResponse(requestParams, isBuffer, makerequest = fetch) { 170 | let status; 171 | return makerequest(...requestParams) 172 | .then((resp) => { 173 | status = resp.status; 174 | if (!resp.ok) { 175 | return resp.text().then((err) => { 176 | const objError = JSON.parse(err); 177 | // particle-commnds/src/cmd/api expects response.text. to be a string 178 | const response = Object.assign(resp, { text: err }); 179 | throw Object.assign(objError, { response }); 180 | }); 181 | } 182 | if (status === 204) { // Can't do resp.json() since there is no body to parse 183 | return ''; 184 | } 185 | if (isBuffer) { 186 | return resp.blob(); 187 | } 188 | return resp.json(); 189 | }).then((body) => { 190 | if (isBuffer) { 191 | return body.arrayBuffer().then((arrayBuffer) => { 192 | if (!this.isForBrowser()) { 193 | return Buffer.from(arrayBuffer); 194 | } 195 | return arrayBuffer; 196 | }); 197 | } 198 | return { 199 | body, 200 | statusCode: status 201 | }; 202 | }).catch((error) => { 203 | const errorType = status ? `HTTP error ${status}` : 'Network error'; 204 | let errorDescription = `${errorType} from ${requestParams[0]}`; 205 | let shortErrorDescription; 206 | if (error.error_description) { // Fetch responded with ok false 207 | errorDescription = `${errorDescription} - ${error.error_description}`; 208 | shortErrorDescription = error.error_description; 209 | } 210 | const reason = new Error(errorDescription); 211 | Object.assign(reason, { 212 | statusCode: status, 213 | errorDescription, 214 | shortErrorDescription, 215 | error, 216 | body: error 217 | }); 218 | throw reason; 219 | }); 220 | } 221 | 222 | /** 223 | * Generate the params in a format valid for 'fetch' 224 | * @param {object} config Configurations to customize the request 225 | * @param {string} config.uri The URI to request 226 | * @param {string} config.method The method used to request the URI, should be in uppercase. 227 | * @param {object} [config.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 228 | * @param {object} [config.data] Arbitrary data to send as the body. 229 | * @param {string} [config.auth] Authorization 230 | * @param {object} [config.query] Query parameters 231 | * @param {object} [config.form] Form fields 232 | * @param {object} [config.files] Array of file names and file content 233 | * @param {object} [config.context] The invocation context, describing the tool and project. 234 | * @returns {[string, object]} The uri to make the request too, and extra configs 235 | * @private 236 | */ 237 | _buildRequest({ uri, method, headers, data, auth, query, form, files, context }){ 238 | let actualUri = uri; 239 | if (this.baseUrl && uri[0] === '/') { 240 | actualUri = `${this.baseUrl}${uri}`; 241 | } 242 | if (query) { 243 | const queryParams = qs.stringify(query); 244 | if (queryParams) { 245 | const hasParams = actualUri.includes('?'); 246 | actualUri = `${actualUri}${hasParams ? '&' : '?'}${queryParams}`; 247 | } 248 | } 249 | 250 | const userAgentHeader = { 'User-Agent': `${packageJson.name}/${packageJson.version} (${packageJson.repository.url})` }; 251 | let body; 252 | let contentTypeHeader; 253 | if (files){ 254 | // @ts-ignore 255 | contentTypeHeader = {}; // Needed to allow fetch create its own 256 | body = this._getFromData(files, form); 257 | } else if (form){ 258 | contentTypeHeader = { 'Content-Type': 'application/x-www-form-urlencoded' }; 259 | body = qs.stringify(form); 260 | } else if (data){ 261 | contentTypeHeader = { 'Content-Type': 'application/json' }; 262 | body = JSON.stringify(data); 263 | } 264 | const finalHeaders = Object.assign({}, 265 | userAgentHeader, 266 | contentTypeHeader, 267 | this._getAuthorizationHeader(auth), 268 | this._getContextHeaders(context), 269 | headers 270 | ); 271 | 272 | return [actualUri, { method, body, headers: finalHeaders }]; 273 | } 274 | 275 | isForBrowser() { 276 | return typeof window !== 'undefined'; 277 | } 278 | 279 | _getFromData(files, form) { 280 | const formData = new FormData(); 281 | for (let [name, file] of Object.entries(files)){ 282 | let path = file.path; 283 | let fileData = file.data; 284 | if (!this.isForBrowser()) { 285 | const nodeFormData = this._getNodeFormData(file); 286 | path = nodeFormData.path; 287 | fileData = nodeFormData.file; 288 | } 289 | formData.append(name, fileData, path); 290 | } 291 | if (form){ 292 | for (let [name, value] of Object.entries(form)){ 293 | formData.append(name, value); 294 | } 295 | } 296 | return formData; 297 | } 298 | 299 | _getNodeFormData(file) { 300 | let fileData = file.data; 301 | if (typeof file.data === 'string') { 302 | fileData = fs.createReadStream(file.data); 303 | } 304 | return { 305 | file: fileData, 306 | path: { filepath: file.path } // Different API for nodejs 307 | }; 308 | } 309 | 310 | _getContextHeaders(context = {}) { 311 | return Object.assign({}, 312 | this._getToolContext(context.tool), 313 | this._getProjectContext(context.project) 314 | ); 315 | } 316 | 317 | _getToolContext(tool = {}){ 318 | let value = ''; 319 | if (tool.name){ 320 | value += this._toolIdent(tool); 321 | if (tool.components){ 322 | for (let component of tool.components){ 323 | value += ', '+this._toolIdent(component); 324 | } 325 | } 326 | } 327 | if (value){ 328 | return { 'X-Particle-Tool': value }; 329 | } 330 | return {}; 331 | } 332 | 333 | _toolIdent(tool){ 334 | return this._nameAtVersion(tool.name, tool.version); 335 | } 336 | 337 | _nameAtVersion(name, version){ 338 | let value = ''; 339 | if (name){ 340 | value += name; 341 | if (version){ 342 | value += '@'+version; 343 | } 344 | } 345 | return value; 346 | } 347 | 348 | _getProjectContext(project = {}){ 349 | let value = this._buildSemicolonSeparatedProperties(project, 'name'); 350 | if (value){ 351 | return { 'X-Particle-Project': value }; 352 | } 353 | return {}; 354 | } 355 | 356 | /** 357 | * Creates a string like primaryPropertyValue; name=value; name1=value 358 | * from the properties of an object. 359 | * @param {object} obj The object to create the string from 360 | * @param {string} primaryProperty The name of the primary property which is the default value and must be defined. 361 | * @private 362 | * @return {string} The formatted string representing the object properties and the default property. 363 | */ 364 | _buildSemicolonSeparatedProperties(obj, primaryProperty){ 365 | let value = ''; 366 | if (obj[primaryProperty]){ 367 | value += obj[primaryProperty]; 368 | for (let prop in obj){ 369 | if (prop!==primaryProperty && obj.hasOwnProperty(prop)){ 370 | value += '; '+prop+'='+obj[prop]; 371 | } 372 | } 373 | } 374 | return value; 375 | } 376 | 377 | /** 378 | * Adds an authorization header. 379 | * @param {string} [auth] The authorization bearer token. 380 | * @returns {object} The original request. 381 | */ 382 | _getAuthorizationHeader(auth){ 383 | if (typeof auth === 'string') { 384 | return { Authorization: `Bearer ${auth}` }; 385 | } 386 | 387 | return {}; 388 | } 389 | 390 | /** 391 | * 392 | * @param {Object} files converts the file names to file, file1, file2. 393 | * @returns {object} the renamed files. 394 | */ 395 | _sanitizeFiles(files){ 396 | let requestFiles; 397 | if (files){ 398 | requestFiles = {}; 399 | Object.keys(files).forEach((k, i) => { 400 | const name = i ? `file${i + 1}` : 'file'; 401 | requestFiles[name] = { 402 | data: files[k], 403 | path: k 404 | }; 405 | }); 406 | } 407 | return requestFiles; 408 | } 409 | } 410 | 411 | module.exports = Agent; 412 | -------------------------------------------------------------------------------- /src/Client.js: -------------------------------------------------------------------------------- 1 | const Library = require('./Library'); 2 | let Particle; 3 | 4 | class Client { 5 | constructor({ auth, api = new Particle() }){ 6 | this.auth = auth; 7 | this.api = api; 8 | } 9 | 10 | ready(){ 11 | return Boolean(this.auth); 12 | } 13 | 14 | /** 15 | * Get firmware library objects 16 | * @param {Object} query The query parameters for libraries. See Particle.listLibraries 17 | * @returns {Promise} A promise 18 | */ 19 | libraries(query = {}){ 20 | return this.api.listLibraries(Object.assign({}, query, { auth: this.auth })) 21 | .then(payload => { 22 | const libraries = payload.body.data || []; 23 | return libraries.map(l => new Library(this, l)); 24 | }); 25 | } 26 | 27 | /** 28 | * Get one firmware library object 29 | * @param {String} name Name of the library to fetch 30 | * @param {Object} query The query parameters for libraries. See Particle.getLibrary 31 | * @returns {Promise} A promise 32 | */ 33 | library(name, query = {}){ 34 | return this.api.getLibrary(Object.assign({}, query, { name, auth: this.auth })) 35 | .then(payload => { 36 | const library = payload.body.data || {}; 37 | return new Library(this, library); 38 | }); 39 | } 40 | 41 | /** 42 | * Get list of library versions 43 | * @param {String} name Name of the library to fetch 44 | * @param {Object} query The query parameters for versions. See Particle.getLibraryVersions 45 | * @returns {Promise} A promise 46 | */ 47 | libraryVersions(name, query = {}){ 48 | return this.api.getLibraryVersions(Object.assign({}, query, { name, auth: this.auth })) 49 | .then(payload => { 50 | const libraries = payload.body.data || []; 51 | return libraries.map(l => new Library(this, l)); 52 | }); 53 | } 54 | 55 | /** 56 | * Contribute a new library version 57 | * @param {Buffer} archive The compressed archive with the library source 58 | * @returns {Promise} A promise 59 | */ 60 | contributeLibrary(archive){ 61 | return this.api.contributeLibrary({ archive, auth: this.auth }) 62 | .then(payload => { 63 | const library = payload.body.data || {}; 64 | return new Library(this, library); 65 | }, error => { 66 | this._throwError(error); 67 | }); 68 | } 69 | 70 | /** 71 | * Make the the most recent private library version public 72 | * @param {string} name The name of the library to publish 73 | * @return {Promise} To publish the library 74 | */ 75 | publishLibrary(name){ 76 | return this.api.publishLibrary({ name, auth: this.auth }) 77 | .then(payload => { 78 | const library = payload.body.data || {}; 79 | return new Library(this, library); 80 | }, error => { 81 | this._throwError(error); 82 | }); 83 | } 84 | 85 | /** 86 | * Delete an entire published library 87 | * @param {object} params Specific params of the library to delete 88 | * @param {string} params.name Name of the library to delete 89 | * @param {string} params.force Key to force deleting a public library 90 | * @returns {Promise} A promise 91 | */ 92 | deleteLibrary({ name, force }){ 93 | return this.api.deleteLibrary({ name, force, auth: this.auth }) 94 | .then(() => true, error => this._throwError(error)); 95 | } 96 | 97 | _throwError(error){ 98 | if (error.body && error.body.errors){ 99 | const errorMessages = error.body.errors.map((e) => e.message).join('\n'); 100 | throw new Error(errorMessages); 101 | } 102 | throw error; 103 | } 104 | 105 | downloadFile(uri){ 106 | return this.api.downloadFile({ uri }); 107 | } 108 | 109 | /** 110 | * @param {Object} files Object containing files to be compiled 111 | * @param {Number} platformId Platform id number of the device you are compiling for 112 | * @param {String} targetVersion System firmware version to compile against 113 | * @returns {Promise} A promise 114 | * @deprecated Will be removed in 6.5 115 | */ 116 | compileCode(files, platformId, targetVersion){ 117 | return this.api.compileCode({ files, platformId, targetVersion, auth: this.auth }); 118 | } 119 | 120 | /** 121 | * @param {object} params 122 | * @param {string} params.deviceId Device ID or Name 123 | * @param {boolean} params.signal Signal on or off 124 | * @returns {Promise} A promise 125 | * @deprecated Will be removed in 6.5 126 | */ 127 | signalDevice({ signal, deviceId }){ 128 | return this.api.signalDevice({ signal, deviceId, auth: this.auth }); 129 | } 130 | 131 | /** 132 | * @returns {Promise} A promise 133 | * @deprecated Will be removed in 6.5 134 | */ 135 | listDevices(){ 136 | return this.api.listDevices({ auth: this.auth }); 137 | } 138 | 139 | /** 140 | * @returns {Promise} A promise 141 | * @deprecated Will be removed in 6.5 142 | */ 143 | listBuildTargets(){ 144 | return this.api.listBuildTargets({ onlyFeatured: true, auth: this.auth }) 145 | .then(payload => { 146 | let targets = []; 147 | for (let target of payload.body.targets){ 148 | for (let platform of target.platforms){ 149 | targets.push({ 150 | version: target.version, 151 | platform: platform, 152 | prerelease: target.prereleases.indexOf(platform) > -1, 153 | firmware_vendor: target.firmware_vendor 154 | }); 155 | } 156 | } 157 | return targets; 158 | }, () => {}); 159 | } 160 | 161 | trackingIdentity({ full = false, context = undefined }={}){ 162 | return this.api.trackingIdentity({ full, context, auth: this.auth }) 163 | .then(payload => { 164 | return payload.body; 165 | }); 166 | } 167 | } 168 | 169 | module.exports = Client; 170 | Particle = require('./Particle'); // Move it to after the export to avoid issue with circular reference 171 | -------------------------------------------------------------------------------- /src/Defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseUrl: 'https://api.particle.io', 3 | clientSecret: 'particle-api', 4 | clientId: 'particle-api', 5 | tokenDuration: 7776000, // 90 days 6 | auth: undefined 7 | }; 8 | -------------------------------------------------------------------------------- /src/EventStream.js: -------------------------------------------------------------------------------- 1 | /* eslint max-depth: 0 */ 2 | const http = require('http'); 3 | const https = require('https'); 4 | const url = require('url'); 5 | const { EventEmitter } = require('events'); 6 | 7 | class EventStream extends EventEmitter { 8 | constructor(uri, token) { 9 | super(); 10 | this.uri = uri; 11 | this.token = token; 12 | this.reconnectInterval = 2000; 13 | this.timeout = 13000; // keep alive can be sent up to 12 seconds after last event 14 | this.data = ''; 15 | this.buf = ''; 16 | 17 | this.parse = this.parse.bind(this); 18 | this.end = this.end.bind(this); 19 | this.idleTimeoutExpired = this.idleTimeoutExpired.bind(this); 20 | } 21 | 22 | connect() { 23 | return new Promise((resolve, reject) => { 24 | const { hostname, protocol, port, path } = url.parse(this.uri); 25 | this.origin = `${protocol}//${hostname}${port ? (':' + port) : ''}`; 26 | 27 | const isSecure = protocol === 'https:'; 28 | const requestor = isSecure ? https : http; 29 | const nonce = global.performance ? global.performance.now() : 0; 30 | const req = requestor.request({ 31 | hostname, 32 | protocol, 33 | // Firefox has issues making multiple fetch requests with the same parameters so add a nonce 34 | path: `${path}?nonce=${nonce}`, 35 | headers: { 36 | 'Authorization': `Bearer ${this.token}` 37 | }, 38 | method: 'get', 39 | // @ts-ignore 40 | port: parseInt(port, 10) || (isSecure ? 443 : 80), 41 | // @ts-ignore 42 | mode: 'prefer-streaming' 43 | }); 44 | 45 | this.req = req; 46 | 47 | let connected = false; 48 | let connectionTimeout = setTimeout(() => { 49 | if (this.req) { 50 | this.req.abort(); 51 | } 52 | reject({ error: new Error('Timeout'), errorDescription: `Timeout connecting to ${this.uri}` }); 53 | }, this.timeout); 54 | 55 | req.on('error', e => { 56 | clearTimeout(connectionTimeout); 57 | 58 | if (connected) { 59 | this.end(); 60 | } else { 61 | reject({ error: e, errorDescription: `Network error from ${this.uri}` }); 62 | } 63 | }); 64 | 65 | req.on('response', res => { 66 | clearTimeout(connectionTimeout); 67 | 68 | const statusCode = res.statusCode; 69 | if (statusCode !== 200) { 70 | let body = ''; 71 | res.on('data', chunk => body += chunk); 72 | res.on('end', () => { 73 | try { 74 | body = JSON.parse(body); 75 | } catch (e) { 76 | // don't bother doing anything special if the JSON.parse fails 77 | // since we are already about to reject the promise anyway 78 | } finally { 79 | let errorDescription = `HTTP error ${statusCode} from ${this.uri}`; 80 | // @ts-ignore 81 | if (body && body.error_description) { 82 | // @ts-ignore 83 | errorDescription += ' - ' + body.error_description; 84 | } 85 | reject({ statusCode, errorDescription, body }); 86 | this.req = undefined; 87 | } 88 | }); 89 | return; 90 | } 91 | 92 | this.data = ''; 93 | this.buf = ''; 94 | 95 | connected = true; 96 | res.on('data', this.parse); 97 | res.once('end', this.end); 98 | this.startIdleTimeout(); 99 | resolve(this); 100 | }); 101 | req.end(); 102 | }); 103 | } 104 | 105 | abort() { 106 | if (this.req) { 107 | this.req.abort(); 108 | this.req = undefined; 109 | } 110 | this.removeAllListeners(); 111 | } 112 | 113 | /* Private methods */ 114 | 115 | emitSafe(event, param) { 116 | try { 117 | this.emit(event, param); 118 | } catch (error) { 119 | if (event !== 'error') { 120 | this.emitSafe('error', error); 121 | } 122 | } 123 | } 124 | 125 | end() { 126 | this.stopIdleTimeout(); 127 | 128 | if (!this.req) { 129 | // request was ended intentionally by abort 130 | // do not auto reconnect. 131 | return; 132 | } 133 | 134 | this.req = undefined; 135 | this.emitSafe('disconnect'); 136 | this.reconnect(); 137 | } 138 | 139 | reconnect() { 140 | setTimeout(() => { 141 | if (this.isOffline()) { 142 | this.reconnect(); 143 | return; 144 | } 145 | 146 | this.emitSafe('reconnect'); 147 | this.connect().then(() => { 148 | this.emitSafe('reconnect-success'); 149 | }).catch(err => { 150 | this.emitSafe('reconnect-error', err); 151 | this.reconnect(); 152 | }); 153 | }, this.reconnectInterval); 154 | } 155 | 156 | isOffline() { 157 | if (typeof navigator === 'undefined' || navigator.hasOwnProperty('onLine')) { 158 | return false; 159 | } 160 | return !navigator.onLine; 161 | } 162 | 163 | startIdleTimeout() { 164 | this.stopIdleTimeout(); 165 | this.idleTimeout = setTimeout(this.idleTimeoutExpired, this.timeout); 166 | } 167 | 168 | stopIdleTimeout() { 169 | if (this.idleTimeout) { 170 | clearTimeout(this.idleTimeout); 171 | this.idleTimeout = null; 172 | } 173 | } 174 | 175 | idleTimeoutExpired() { 176 | if (this.req) { 177 | this.req.abort(); 178 | this.end(); 179 | } 180 | } 181 | 182 | parse(chunk) { 183 | this.startIdleTimeout(); 184 | 185 | this.buf += chunk; 186 | let pos = 0; 187 | let length = this.buf.length; 188 | let discardTrailingNewline = false; 189 | 190 | while (pos < length) { 191 | if (discardTrailingNewline) { 192 | if (this.buf[pos] === '\n') { 193 | ++pos; 194 | } 195 | discardTrailingNewline = false; 196 | } 197 | 198 | let lineLength = -1; 199 | let fieldLength = -1; 200 | 201 | for (let i = pos; lineLength < 0 && i < length; ++i) { 202 | const c = this.buf[i]; 203 | if (c === ':') { 204 | if (fieldLength < 0) { 205 | fieldLength = i - pos; 206 | } 207 | } else if (c === '\r') { 208 | discardTrailingNewline = true; 209 | lineLength = i - pos; 210 | } else if (c === '\n') { 211 | lineLength = i - pos; 212 | } 213 | } 214 | 215 | if (lineLength < 0) { 216 | break; 217 | } 218 | 219 | this.parseEventStreamLine(pos, fieldLength, lineLength); 220 | 221 | pos += lineLength + 1; 222 | } 223 | 224 | if (pos === length) { 225 | this.buf = ''; 226 | } else if (pos > 0) { 227 | this.buf = this.buf.slice(pos); 228 | } 229 | } 230 | 231 | parseEventStreamLine(pos, fieldLength, lineLength) { 232 | if (lineLength === 0) { 233 | try { 234 | if (this.data.length > 0 && this.event) { 235 | const event = JSON.parse(this.data); 236 | event.name = this.eventName || ''; 237 | this.emitSafe('event', event); 238 | } 239 | } catch (e) { 240 | // do nothing if JSON.parse fails 241 | } finally { 242 | this.data = ''; 243 | this.eventName = undefined; 244 | this.event = false; 245 | } 246 | } else if (fieldLength > 0) { 247 | const field = this.buf.slice(pos, pos + fieldLength); 248 | let step = 0; 249 | 250 | if (this.buf[pos + fieldLength + 1] !== ' ') { 251 | step = fieldLength + 1; 252 | } else { 253 | step = fieldLength + 2; 254 | } 255 | pos += step; 256 | const valueLength = lineLength - step; 257 | const value = this.buf.slice(pos, pos + valueLength); 258 | 259 | if (field === 'data') { 260 | this.data += value + '\n'; 261 | } else if (field === 'event') { 262 | this.eventName = value; 263 | this.event = true; 264 | } 265 | } 266 | } 267 | } 268 | 269 | module.exports = EventStream; 270 | -------------------------------------------------------------------------------- /src/Library.js: -------------------------------------------------------------------------------- 1 | /* Library 2 | * Represents a version of a library contributed in the cloud. 3 | */ 4 | 5 | class Library { 6 | constructor(client, data) { 7 | // Make client non-enumerable so it doesn't show up in Object.keys, JSON.stringify, etc 8 | Object.defineProperty(this, 'client', { value: client }); 9 | this._assignAttributes(data); 10 | this.downloadUrl = data.links && data.links.download; 11 | } 12 | 13 | _assignAttributes(data) { 14 | Object.assign(this, data.attributes); 15 | } 16 | 17 | 18 | /** 19 | * Download the compressed file containing the source code for this library version. 20 | * @return {Promise} Resolves to the .tar.gz compressed source code 21 | */ 22 | download() { 23 | if (!this.downloadUrl) { 24 | return Promise.reject(new Error('No download URL for this library')); 25 | } 26 | // @ts-ignore 27 | return this.client.downloadFile(this.downloadUrl); 28 | } 29 | 30 | /* TODO: add a versions() method to fetch an array of library objects */ 31 | } 32 | 33 | module.exports = Library; 34 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true, 4 | "node": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/Agent.integration.js: -------------------------------------------------------------------------------- 1 | /* 2 | ****************************************************************************** 3 | Copyright (c) 2016 Particle Industries, Inc. All rights reserved. 4 | 5 | This program is free software; you can redistribute it and/or 6 | modify it under the terms of the GNU Lesser General Public 7 | License as published by the Free Software Foundation, either 8 | version 3 of the License, or (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | Lesser General Public License for more details. 14 | 15 | You should have received a copy of the GNU Lesser General Public 16 | License along with this program; if not, see . 17 | ****************************************************************************** 18 | */ 19 | 20 | /** 21 | * Tests for real the Agent class using an external service. 22 | */ 23 | 24 | const { expect } = require('./test-setup'); 25 | const Agent = require('../src/Agent'); 26 | 27 | describe('Agent', () => { 28 | if (!process.env.SKIP_AGENT_TEST){ 29 | it('can fetch a webpage', function cb() { 30 | this.retries(5); 31 | this.timeout(6000); 32 | const agent = new Agent(); 33 | const query = { a: '1', b: '2' }; 34 | const result = agent.get({ uri: 'http://httpbin.org/get', query }); 35 | return result.then((res)=> { 36 | expect(res.statusCode).to.equal(200); 37 | expect(res).has.property('body'); 38 | expect(res.body.args).to.deep.equal(query); 39 | }); 40 | }); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /test/Agent.spec.js: -------------------------------------------------------------------------------- 1 | const { sinon, expect } = require('./test-setup'); 2 | const Agent = require('../src/Agent.js'); 3 | 4 | describe('Agent', () => { 5 | beforeEach(() => { 6 | sinon.restore(); 7 | }); 8 | 9 | describe('constructor', () => { 10 | it('calls setBaseUrl', () => { 11 | const baseUrl = 'https://foo.com'; 12 | sinon.stub(Agent.prototype, 'setBaseUrl'); 13 | const agent = new Agent(baseUrl); 14 | expect(agent.setBaseUrl).to.have.property('callCount', 1); 15 | expect(agent.setBaseUrl.firstCall.args).to.have.lengthOf(1); 16 | expect(agent.setBaseUrl.firstCall.args[0]).to.eql(baseUrl); 17 | }); 18 | }); 19 | 20 | describe('sanitize files', () => { 21 | it('can call sanitize will falsy value', () => { 22 | const agent = new Agent(); 23 | expect(agent._sanitizeFiles(undefined)).to.be.undefined; 24 | }); 25 | 26 | it('sanitizes file names', () => { 27 | const agent = new Agent(); 28 | const original = { one: 'content1', two: 'content2' }; 29 | const actual = agent._sanitizeFiles(original); 30 | expect(actual).to.eql({ 31 | 'file': { 32 | 'data': 'content1', 33 | 'path': 'one' 34 | }, 35 | 'file2': { 36 | 'data': 'content2', 37 | 'path': 'two' 38 | } 39 | }); 40 | }); 41 | }); 42 | 43 | describe('resource operations', () => { 44 | let uri, method, auth, headers, query, data, context, agent; 45 | 46 | beforeEach(() => { 47 | uri = 'http://example.com/v1'; 48 | method = 'get'; 49 | auth = 'fake-token'; 50 | headers = { 'X-FOO': 'foo', 'X-BAR': 'bar' }; 51 | query = 'foo=1&bar=2'; 52 | data = { foo: true, bar: false }; 53 | context = { blah: {} }; 54 | agent = new Agent(); 55 | agent.request = sinon.stub(); 56 | agent.request.resolves('fake-response'); 57 | }); 58 | 59 | it('can GET a resource', () => { 60 | return agent.get({ uri, auth, headers, query, context }).then(() => { 61 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, query, context }); 62 | }); 63 | }); 64 | 65 | it('can HEAD a resource', () => { 66 | method = 'head'; 67 | return agent.head({ uri, auth, headers, query, context }).then(() => { 68 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, query, context }); 69 | }); 70 | }); 71 | 72 | it('can POST a resource', () => { 73 | method = 'post'; 74 | return agent.post({ uri, auth, headers, data, context }).then(() => { 75 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context }); 76 | }); 77 | }); 78 | 79 | it('can PUT a resource', () => { 80 | method = 'put'; 81 | return agent.put({ uri, auth, headers, data, context, query }).then(() => { 82 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context, query }); 83 | }); 84 | }); 85 | 86 | it('can DELETE a resource', () => { 87 | method = 'delete'; 88 | return agent.delete({ uri, auth, headers, data, context }).then(() => { 89 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context }); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('authorize', () => { 95 | let agent; 96 | 97 | beforeEach(() => { 98 | agent = new Agent(); 99 | }); 100 | 101 | it('authorize no auth is unchanged', () => { 102 | expect(agent._getAuthorizationHeader(undefined)).to.eql({}); 103 | }); 104 | 105 | it('authorize with bearer', () => { 106 | const auth = '123'; 107 | const bearer = 'Bearer 123'; 108 | const headers = agent._getAuthorizationHeader(auth); 109 | expect(headers).to.eql({ Authorization: bearer }); 110 | }); 111 | }); 112 | 113 | describe('request', () => { 114 | let agent; 115 | 116 | beforeEach(() => { 117 | agent = new Agent(); 118 | agent._promiseResponse = sinon.stub(); 119 | agent._promiseResponse.resolves('fake-response'); 120 | agent._buildRequest = sinon.stub(); 121 | agent._sanitizeFiles = sinon.stub(); 122 | }); 123 | 124 | it('sanitizes files from a request', () => { 125 | const sanitizedFiles = { a:'a' }; 126 | const files = {}; 127 | const form = {}; 128 | agent._sanitizeFiles.returns(sanitizedFiles); 129 | 130 | return agent.request({ uri: 'abc', method: 'post', data: '123', query: 'all', form, files }) 131 | .then((res) => { 132 | expect(res).to.be.equal('fake-response'); 133 | expect(agent._sanitizeFiles).calledOnce.calledWith(sinon.match.same(files)); 134 | }); 135 | }); 136 | 137 | it('uses default arguments for request', () => { 138 | const args = ['abc', { args: '123' }]; 139 | agent._buildRequest.returns(args); 140 | return agent.request({ uri: 'abc', method:'post' }) 141 | .then((res) => { 142 | expect(res).to.be.equal('fake-response'); 143 | expect(agent._promiseResponse).calledOnce.calledWith(args); 144 | }); 145 | }); 146 | 147 | it('builds and sends the request', () => { 148 | const agent = new Agent(); 149 | const options = { 150 | uri: 'http://example.com/v1', 151 | method: 'get', 152 | auth: 'fake-token', 153 | headers: { 'X-FOO': 'foo', 'X-BAR': 'bar' }, 154 | query: 'foo=1&bar=2', 155 | data: { foo: true, bar: false }, 156 | files: undefined, 157 | form: undefined, 158 | context 159 | }; 160 | agent._buildRequest = sinon.stub(); 161 | agent._buildRequest.returns('fake-request'); 162 | agent._promiseResponse = sinon.stub(); 163 | agent._promiseResponse.resolves('fake-response'); 164 | 165 | return agent.request(options).then((res) => { 166 | expect(res).to.be.equal('fake-response'); 167 | expect(agent._buildRequest).calledOnce; 168 | expect(agent._buildRequest).calledWith(options); 169 | expect(agent._promiseResponse).calledOnce; 170 | expect(agent._promiseResponse).calledWith('fake-request'); 171 | }); 172 | }); 173 | 174 | it('builds a promise to call _promiseResponse', () => { 175 | const agent = new Agent(); 176 | const req = sinon.stub(); 177 | const response = { 178 | ok: true, 179 | status: 200, 180 | json: () => Promise.resolve('response') 181 | }; 182 | req.resolves(response); 183 | const promise = agent._promiseResponse([], false, req); 184 | expect(promise).has.property('then'); 185 | return promise.then((resp) => { 186 | expect(resp).to.be.eql({ 187 | body: 'response', 188 | statusCode: 200 189 | }); 190 | }); 191 | }); 192 | 193 | it('can handle error responses', () => { 194 | const failResponseData = [ 195 | { 196 | name: 'error text includes body error description', 197 | response: { 198 | status: 404, 199 | statusText: 'file not found', 200 | text: () => Promise.resolve('{"error_description": "file not found"}') 201 | }, 202 | errorDescription: 'HTTP error 404 from 123.url - file not found' 203 | }, 204 | { 205 | name: 'error text with no body description', 206 | response: { 207 | status: 404, 208 | text: () => Promise.resolve(''), 209 | }, 210 | errorDescription: 'HTTP error 404 from 123.url' 211 | }, 212 | { 213 | name: 'error text with no status', 214 | response: {}, 215 | errorDescription: 'Network error from 123.url' 216 | } 217 | ]; 218 | const agent = new Agent(); 219 | const req = sinon.stub(); 220 | const requests = failResponseData.map((failData) => { 221 | const response = Object.assign({ 222 | ok: false 223 | }, failData.response); 224 | req.resolves(response); 225 | const promise = agent._promiseResponse(['123.url'] , false, req); 226 | return promise.catch((resp) => { 227 | expect(resp.statusCode).to.eql(failData.response.status); 228 | expect(resp.errorDescription).to.eql(failData.errorDescription); 229 | expect(resp.shortErrorDescription).to.eql(failData.response.statusText); 230 | }); 231 | }); 232 | return Promise.all(requests); 233 | }); 234 | }); 235 | 236 | describe('build request', () => { 237 | let agent; 238 | 239 | beforeEach(() => { 240 | agent = new Agent('abc'); 241 | }); 242 | 243 | it('uses a baseURL if provided', () => { 244 | const [uri] = agent._buildRequest({ uri: '/uri', method: 'get' }); 245 | expect(uri).to.equal('abc/uri'); 246 | }); 247 | 248 | it('uses the provided uri if no baseURL is provided', () => { 249 | agent.setBaseUrl(undefined); 250 | const [uri] = agent._buildRequest({ uri: 'uri', method: 'get' }); 251 | expect(uri).to.equal('uri'); 252 | }); 253 | 254 | it('generates context headers when one is provided', () => { 255 | const context = { tool: { name: 'spanner' } }; 256 | const [, opts] = agent._buildRequest({ uri: '/uri', method: 'get', context }); 257 | expect(opts.headers).to.have.property('X-Particle-Tool', 'spanner'); 258 | }); 259 | 260 | it('generates auth headers when an auth token is provided', () => { 261 | const auth = 'abcd-1235'; 262 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', auth }); 263 | expect(opts.headers).to.have.property('Authorization', `Bearer ${auth}`); 264 | }); 265 | 266 | it('adds new query params with the given query object', () => { 267 | const query = { foo: 1, bar: 2 }; 268 | const [uri] = agent._buildRequest({ uri: '/uri', method: 'get', query }); 269 | expect(uri).to.equal('abc/uri?foo=1&bar=2'); 270 | }); 271 | 272 | it('adds query params without colliding with existing ones', () => { 273 | const query = { foo: 1, bar: 2 }; 274 | const [uri] = agent._buildRequest({ uri: '/uri?test=true', method: 'get', query }); 275 | expect(uri).to.equal('abc/uri?test=true&foo=1&bar=2'); 276 | }); 277 | 278 | it('adds the provided data as a JSON request body', () => { 279 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', data: { a: 'abcd' } }); 280 | expect(opts.body).to.eql('{"a":"abcd"}'); 281 | expect(opts.headers).to.have.property('Content-Type', 'application/json'); 282 | }); 283 | 284 | it('should setup form send when form data is given', () => { 285 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', form: { a: 'abcd' } }); 286 | expect(opts.body).to.eql('a=abcd'); 287 | }); 288 | 289 | it('should attach files', () => { 290 | const files = { 291 | file: { data: makeFile('filedata'), path: 'filepath' }, 292 | file2: { data: makeFile('file2data'), path: 'file2path' } 293 | }; 294 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 295 | expect(opts.body.toString()).to.equal('[object FormData]'); 296 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath'); 297 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('file2path'); 298 | expect(opts.headers).to.not.have.property('Content-Type'); 299 | }); 300 | 301 | it('should attach files and form data', () => { 302 | const files = { 303 | file: { data: makeFile('filedata'), path: 'filepath' }, 304 | file2: { data: makeFile('file2data'), path: 'file2path' } 305 | }; 306 | const form = { form1: 'value1', form2: 'value2' }; 307 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files, form }); 308 | expect(opts.body.toString()).to.equal('[object FormData]'); 309 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath'); 310 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('file2path'); 311 | expect(extractFormName(opts.body, 'form1', 6, true)).to.eql('value1'); 312 | expect(extractFormName(opts.body, 'form2', 9, true)).to.eql('value2'); 313 | expect(opts.headers).to.not.have.property('Content-Type'); 314 | }); 315 | 316 | it('should handle nested dirs', () => { 317 | const files = { 318 | file: { data: makeFile('filedata'), path: 'filepath.ino' }, 319 | file2: { data: makeFile('file2data'), path: 'dir/file2path.cpp' } 320 | }; 321 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 322 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath.ino'); 323 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('dir/file2path.cpp'); 324 | }); 325 | 326 | it('sets the user agent to particle-api-js', () => { 327 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get' }); 328 | expect(opts.headers).to.have.property('User-Agent').that.match(/^particle-api-js/); 329 | }); 330 | 331 | if (!inBrowser()){ 332 | it('should handle Windows nested dirs', () => { 333 | const files = { 334 | file: { data: makeFile('filedata'), path: 'dir\\windowsfilepath.cpp' } 335 | }; 336 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 337 | expect(extractFilename(opts.body, 'file', 0)).to.eql('dir/windowsfilepath.cpp'); 338 | }); 339 | } 340 | 341 | function inBrowser(){ 342 | return typeof window !== 'undefined'; 343 | } 344 | 345 | function makeFile(data){ 346 | if (inBrowser()){ 347 | return new Blob([data]); 348 | } else { 349 | return data; 350 | } 351 | } 352 | 353 | function extractFilename(formData, fieldName, fieldIndex){ 354 | if (inBrowser()){ 355 | return formData.get(fieldName).name; 356 | } else { 357 | return /filename="([^"]*)"/.exec(formData._streams[fieldIndex])[1]; 358 | } 359 | } 360 | 361 | function extractFormName(formData, fieldName, fieldIndex){ 362 | if (inBrowser()){ 363 | return formData.get(fieldName); 364 | } else { 365 | return formData._streams[fieldIndex + 1]; 366 | } 367 | } 368 | }); 369 | 370 | describe('context', () => { 371 | let agent; 372 | 373 | beforeEach(() => { 374 | agent = new Agent(); 375 | }); 376 | 377 | describe('_nameAtVersion', () => { 378 | it('returns empty string when no name given', () => { 379 | expect(agent._nameAtVersion('', '1.2.3')).to.eql(''); 380 | }); 381 | 382 | it('returns just the name when no version given', () => { 383 | expect(agent._nameAtVersion('fred')).to.eql('fred'); 384 | }); 385 | 386 | it('returns name@version when both are given', () => { 387 | expect(agent._nameAtVersion('fred', '1.2.3')).to.eql('fred@1.2.3'); 388 | }); 389 | }); 390 | 391 | describe('_getContextHeaders', () => { 392 | it('generates the tool context when defined', () => { 393 | const context = { tool: { name: 'spanner' } }; 394 | const subject = agent._getContextHeaders(context); 395 | expect(subject).to.have.property('X-Particle-Tool', 'spanner'); 396 | }); 397 | 398 | it('does not add the tool context header when not defined',() => { 399 | const context = { tool: { name2: 'spanner' } }; 400 | const subject = agent._getContextHeaders(context); 401 | expect(subject).to.not.have.property('X-Particle-Tool'); 402 | }); 403 | 404 | it('generates the project context header when defined',() => { 405 | const context = { project: { name: 'blinky' } }; 406 | const subject = agent._getContextHeaders(context); 407 | expect(subject).to.have.property('X-Particle-Project', 'blinky'); 408 | }); 409 | 410 | it('does not generate the project context header when not defined',() => { 411 | const context = { project: { name2: 'blinky' } }; 412 | const subject = agent._getContextHeaders(context); 413 | expect(subject).to.not.have.property('X-Particle-Project'); 414 | }); 415 | }); 416 | 417 | describe('_getToolContext', () => { 418 | it('does not add a header when the tool name is not defined', () => { 419 | const tool = { noname: 'cli' }; 420 | const subject = agent._getToolContext(tool); 421 | expect(subject).to.eql({}); 422 | }); 423 | 424 | it('adds a header when the tool is defined', () => { 425 | const tool = { name: 'cli' }; 426 | const subject = agent._getToolContext(tool); 427 | expect(subject).to.eql({ 'X-Particle-Tool': 'cli' }); 428 | }); 429 | 430 | it('adds a header when the tool and components is defined', () => { 431 | const tool = { 432 | name: 'cli', 433 | version: '1.2.3', 434 | components: [ 435 | { name: 'bar', version: 'a.b.c' }, 436 | { name: 'foo', version: '0.0.1' } 437 | ] 438 | }; 439 | const subject = agent._getToolContext(tool); 440 | expect(subject).to.eql({ 'X-Particle-Tool': 'cli@1.2.3, bar@a.b.c, foo@0.0.1' }); 441 | }); 442 | }); 443 | 444 | describe('_addProjectContext', () => { 445 | it('adds a header when the project is defined', () => { 446 | const project = { name: 'blinky' }; 447 | const subject = agent._getProjectContext(project); 448 | expect(subject).to.have.property('X-Particle-Project', 'blinky'); 449 | }); 450 | 451 | it('does not set the header when the project has no name', () => { 452 | const project = { noname: 'blinky' }; 453 | const subject = agent._getProjectContext(project); 454 | expect(subject).to.not.have.property('X-Particle-Project'); 455 | }); 456 | }); 457 | 458 | describe('_buildSemicolonSeparatedProperties', () => { 459 | const obj = { name: 'fred', color: 'pink' }; 460 | 461 | it('returns empty string when no default property', () => { 462 | expect(agent._buildSemicolonSeparatedProperties(obj)).to.be.eql(''); 463 | }); 464 | 465 | it('returns empty string when default property does not exist', () => { 466 | expect(agent._buildSemicolonSeparatedProperties(obj, 'job')).to.be.eql(''); 467 | }); 468 | 469 | it('returns the default property only', () => { 470 | expect(agent._buildSemicolonSeparatedProperties({ name:'fred' }, 'name')).eql('fred'); 471 | }); 472 | 473 | it('returns the default property plus additional properties', () => { 474 | expect(agent._buildSemicolonSeparatedProperties(obj, 'name')).eql('fred; color=pink'); 475 | }); 476 | }); 477 | }); 478 | }); 479 | -------------------------------------------------------------------------------- /test/Client.spec.js: -------------------------------------------------------------------------------- 1 | const { expect, sinon } = require('./test-setup'); 2 | const Client = require('../src/Client'); 3 | const fixtures = require('./fixtures'); 4 | const Library = require('../src/Library'); 5 | 6 | let api; 7 | const token = 'tok'; 8 | let client; 9 | 10 | 11 | describe('Client', () => { 12 | beforeEach(() => { 13 | api = {}; 14 | client = new Client({ api: api, auth: token }); 15 | }); 16 | 17 | describe('constructor', () => { 18 | it('sets the auth token', () => { 19 | expect(client.auth).to.equal(token); 20 | }); 21 | it('sets the api', () => { 22 | expect(client.api).to.equal(api); 23 | }); 24 | }); 25 | 26 | describe('libraries', () => { 27 | it('resolves to a list of Library objects', () => { 28 | api.listLibraries = () => Promise.resolve({ body: fixtures.read('libraries.json') }); 29 | return client.libraries().then(libraries => { 30 | expect(libraries.length).to.equal(1); 31 | expect(libraries[0].name).to.equal('neopixel'); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('library', () => { 37 | it('resolves to a Library objects', () => { 38 | api.getLibrary = () => Promise.resolve({ body: fixtures.read('library.json') }); 39 | return client.library('neopixel').then(library => { 40 | expect(library.name).to.equal('neopixel'); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('libraryVersions', () => { 46 | it('resolves to a Library objects', () => { 47 | api.getLibraryVersions = () => Promise.resolve({ body: fixtures.read('libraryVersions.json') }); 48 | return client.libraryVersions().then(libraries => { 49 | expect(libraries.length).to.equal(9); 50 | expect(libraries[0].name).to.equal('neopixel'); 51 | expect(libraries[0].version).to.equal('0.0.10'); 52 | expect(libraries[1].version).to.equal('0.0.9'); 53 | }); 54 | }); 55 | }); 56 | 57 | describe('downloadFile', () => { 58 | it('delegates to api', () => { 59 | api.downloadFile = ({ uri }) => Promise.resolve(`${uri} delegated`); 60 | return expect(client.downloadFile('uri')).to.eventually.equal('uri delegated'); 61 | }); 62 | }); 63 | 64 | describe('compileCode', () => { 65 | it('delegates to api', () => { 66 | api.compileCode = ({ files, platformId, targetVersion, auth }) => { 67 | return Promise.resolve([files, platformId, targetVersion, auth]); 68 | }; 69 | return expect(client.compileCode('a', 'b', 'c')).to.eventually.eql(['a', 'b', 'c', client.auth]); 70 | }); 71 | }); 72 | 73 | describe('signalDevice', () => { 74 | it('delegates to api', () => { 75 | api.signalDevice = () => { 76 | return Promise.resolve([true, client.auth]); 77 | }; 78 | return expect(client.signalDevice({ deviceId: 'testid', signal: true })) 79 | .to.eventually.eql([true, client.auth]); 80 | }); 81 | }); 82 | 83 | describe('publishLibrary', () => { 84 | it('delegates to api and returns the library metadata on success', () => { 85 | const name = 'fred'; 86 | const metadata = { name }; 87 | const library = new Library(client, metadata); 88 | api.publishLibrary = sinon.stub().resolves({ body: { data: metadata } }); 89 | return client.publishLibrary(name) 90 | .then(actual => { 91 | expect(actual).to.eql(library); 92 | expect(api.publishLibrary).to.have.been.calledWith({ name, auth:token }); 93 | }); 94 | }); 95 | 96 | it('delegates to api and calls _throwError to handle the error', () => { 97 | const error = { message:'I don\'t like vegetables' }; 98 | api.publishLibrary = sinon.stub().rejects(error); 99 | const name = 'notused'; 100 | return client.publishLibrary(name) 101 | .then(() => { 102 | throw new Error('expected an exception'); 103 | }) 104 | .catch(actual => { 105 | expect(actual).to.eql(error); 106 | expect(api.publishLibrary).to.have.been.calledWith({ name, auth:token }); 107 | }); 108 | }); 109 | }); 110 | 111 | describe('contributeLibrary', () => { 112 | it('delegates to api and returns the library metadata on success', () => { 113 | const archive = {}; 114 | const metadata = { name:'' }; 115 | const library = new Library(client, metadata); 116 | api.contributeLibrary = sinon.stub().resolves({ body: { data: metadata } }); 117 | return client.contributeLibrary(archive) 118 | .then(actual => { 119 | expect(actual).to.eql(library); 120 | expect(api.contributeLibrary).to.have.been.calledWith({ archive, auth:token }); 121 | }); 122 | }); 123 | 124 | it('delegates to api and calls _throwError to handle the error', () => { 125 | const archive = {}; 126 | const error = { message:'I don\'t like vegetables' }; 127 | api.contributeLibrary = sinon.stub().rejects(error); 128 | return client.contributeLibrary(archive) 129 | .then(() => { 130 | throw new Error('expected an exception'); 131 | }) 132 | .catch(actual => { 133 | expect(actual).to.eql(error); 134 | expect(api.contributeLibrary).to.have.been.calledWith({ archive, auth:token }); 135 | }); 136 | }); 137 | }); 138 | 139 | describe('listDevices', () => { 140 | it('delegates to api', () => { 141 | api.listDevices = ({ auth }) => { 142 | return Promise.resolve([auth]); 143 | }; 144 | return expect(client.listDevices()).to.eventually.eql([client.auth]); 145 | }); 146 | }); 147 | 148 | describe('listBuildTargets', () => { 149 | it('delegates to api', () => { 150 | const response = { 151 | targets: [ 152 | { 153 | platforms: [0, 6], 154 | prereleases: [], 155 | version: '1.2.3', 156 | firmware_vendor: 'Foo' 157 | }, { 158 | platforms: [6, 8], 159 | prereleases: [6], 160 | version: '4.5.6', 161 | firmware_vendor: 'Bar' 162 | } 163 | ] 164 | }; 165 | const expected = [ 166 | { 167 | version: '1.2.3', 168 | platform: 0, 169 | prerelease: false, 170 | firmware_vendor: 'Foo' 171 | }, { 172 | version: '1.2.3', 173 | platform: 6, 174 | prerelease: false, 175 | 176 | firmware_vendor: 'Foo' 177 | }, { 178 | version: '4.5.6', 179 | platform: 6, 180 | prerelease: true, 181 | firmware_vendor: 'Bar' 182 | }, { 183 | version: '4.5.6', 184 | platform: 8, 185 | prerelease: false, 186 | firmware_vendor: 'Bar' 187 | }, 188 | ]; 189 | api.listBuildTargets = () => { 190 | return Promise.resolve({ body: response }); 191 | }; 192 | return expect(client.listBuildTargets()).to.eventually.eql(expected); 193 | }); 194 | }); 195 | 196 | describe('trackingIdentity', () => { 197 | it('delegates to api and unpacks the body', () => { 198 | api.trackingIdentity = ({ auth, full, context }) => { 199 | return Promise.resolve({ body: { auth, full, context } }); 200 | }; 201 | const context = { abd:123 }; 202 | const full = 456; 203 | return expect(client.trackingIdentity({ full, context })).to.eventually.eql({ auth: client.auth, full, context }); 204 | }); 205 | 206 | it('delegates to api with default parameters and unpacks the body', () => { 207 | api.trackingIdentity = ({ auth, full, context }) => { 208 | return Promise.resolve({ body: { auth, full, context } }); 209 | }; 210 | const context = undefined; 211 | const full = false; 212 | return expect(client.trackingIdentity()).to.eventually.eql({ auth:client.auth, full, context }); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /test/Defaults.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('./test-setup'); 2 | const Defaults = require('../src/Defaults'); 3 | 4 | describe('Default Particle constructor options', () => { 5 | it('includes baseUrl', () => { 6 | expect(Defaults).to.have.property('baseUrl'); 7 | expect(Defaults.baseUrl).to.eql('https://api.particle.io'); 8 | }); 9 | 10 | it('includes clientSecret', () => { 11 | expect(Defaults).to.have.property('clientSecret'); 12 | expect(Defaults.clientSecret).to.eql('particle-api'); 13 | }); 14 | 15 | it('includes clientId', () => { 16 | expect(Defaults).to.have.property('clientId'); 17 | expect(Defaults.clientId).to.eql('particle-api'); 18 | }); 19 | 20 | it('includes tokenDuration', () => { 21 | expect(Defaults).to.have.property('tokenDuration'); 22 | expect(Defaults.tokenDuration).to.eql(7776000); 23 | }); 24 | 25 | it('includes defaultAuth', () => { 26 | expect(Defaults).to.have.property('auth'); 27 | expect(Defaults.auth).to.eql(undefined); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/EventStream.feature: -------------------------------------------------------------------------------- 1 | # End-to-end tests for the EventStream feature 2 | # These tests are hard to automate, especially in the browser since they involve error handling with the API 3 | # so the tests are documented in Gerkin format and are meant to be executed manually 4 | Feature: EventStream 5 | 6 | Scenario: Connect to event stream 7 | Given the API is running 8 | And the API access token is valid 9 | When I run the end-to-end test program 10 | Then the output should contain "event stream connected" 11 | 12 | Scenario: Receive event 13 | Given the event stream is connected 14 | When I publish a Particle event through the CLI with `particle publish test` 15 | Then the event "event" with data "{ name: 'test', ... }" should be output 16 | 17 | Scenario: Initial connection failure 18 | Given the API is not running 19 | When I run the end-to-end test program 20 | Then the output should contain "Error: connect ECONNREFUSED" 21 | And the end-to-end test program should exit 22 | 23 | Scenario: Invalid credentials 24 | Given the API is running 25 | And the API access token is not valid 26 | When I run the end-to-end test program 27 | Then the output should contain "The access token provided is invalid" 28 | And the end-to-end test program should exit 29 | 30 | Scenario: Initial connection timeout 31 | Given the API is paused (press Ctrl-Z in the API terminal) 32 | When I run the end-to-end test program 33 | Then the output should contain "Timeout" 34 | And the end-to-end test program should exit 35 | 36 | Scenario: Disconnect due to connection failure 37 | Given the event stream is connected 38 | When I stop the API (press Ctrl-C in the API terminal) 39 | Then the event "disconnect" should be output immediately 40 | And after 2 seconds, the event "reconnect" should be output 41 | And the event "reconnect-error" should be output immediately 42 | And the "reconnect" and "reconnect-error" should repeat indefinitely 43 | 44 | Scenario: Reconnect after connection failure 45 | Given the event stream is trying to reconnect after connection failure 46 | When I start the API 47 | Then the event "reconnect" followed by "reconnect-success" should be output 48 | 49 | Scenario: Disconnect due to idle timeout 50 | Given the event stream is connected 51 | When I pause the API (press Ctrl-Z in the API terminal) 52 | Then after up to 13 seconds, the event "disconnect" should be output 53 | And after 2 seconds, the event "reconnect" should be output 54 | And after 13 seconds, the event "reconnect-error" should be output 55 | And the "reconnect" and "reconnect-error" should repeat indefinitely 56 | 57 | Scenario: Reconnect after idle timeout 58 | Given the event stream is trying to reconnect after idle timeout 59 | When I unpause the API (type % in bash to resume the process) 60 | Then the event "reconnect-success" should be output 61 | 62 | Scenario: Receive event after reconnect 63 | Given the event stream reconnected after a connection failure 64 | When I publish a Particle event through the CLI with `particle publish test` 65 | Then the event "event" should be published once 66 | -------------------------------------------------------------------------------- /test/EventStream.spec.js: -------------------------------------------------------------------------------- 1 | const { sinon, expect } = require('./test-setup'); 2 | const http = require('http'); 3 | const { EventEmitter } = require('events'); 4 | 5 | const EventStream = require('../src/EventStream'); 6 | 7 | describe('EventStream', () => { 8 | afterEach(() => { 9 | sinon.restore(); 10 | }); 11 | 12 | function makeRequest() { 13 | const fakeRequest = new EventEmitter(); 14 | fakeRequest.end = sinon.spy(); 15 | fakeRequest.setTimeout = sinon.spy(); 16 | return fakeRequest; 17 | } 18 | 19 | function makeResponse(statusCode) { 20 | const fakeResponse = new EventEmitter(); 21 | fakeResponse.statusCode = statusCode; 22 | return fakeResponse; 23 | } 24 | 25 | describe('constructor', () => { 26 | it('creates an EventStream objects', () => { 27 | const eventStream = new EventStream('uri', 'token'); 28 | 29 | expect(eventStream).to.own.include({ uri: 'uri', token: 'token' }); 30 | }); 31 | }); 32 | 33 | describe('connect', () => { 34 | it('successfully connects to http', () => { 35 | sinon.useFakeTimers({ shouldAdvanceTime: true }); 36 | const fakeRequest = makeRequest(); 37 | sinon.stub(http, 'request').callsFake(() => { 38 | setImmediate(() => { 39 | const fakeResponse = makeResponse(200); 40 | fakeRequest.emit('response', fakeResponse); 41 | }); 42 | 43 | return fakeRequest; 44 | }); 45 | 46 | const eventStream = new EventStream('http://hostname:8080/path', 'token'); 47 | 48 | return eventStream.connect().then(() => { 49 | expect(http.request).to.have.been.calledWith({ 50 | hostname: 'hostname', 51 | protocol: 'http:', 52 | path: '/path?nonce=0', 53 | headers: { 54 | 'Authorization': 'Bearer token' 55 | }, 56 | method: 'get', 57 | port: 8080, 58 | mode: 'prefer-streaming' 59 | }); 60 | }); 61 | }); 62 | 63 | it('returns http errors on connect', () => { 64 | sinon.useFakeTimers({ shouldAdvanceTime: true }); 65 | const fakeRequest = makeRequest(); 66 | sinon.stub(http, 'request').callsFake(() => { 67 | setImmediate(() => { 68 | const fakeResponse = makeResponse(500); 69 | fakeRequest.emit('response', fakeResponse); 70 | setImmediate(() => { 71 | fakeResponse.emit('data', '{"error":"unknown"}'); 72 | fakeResponse.emit('end'); 73 | }); 74 | }); 75 | 76 | return fakeRequest; 77 | }); 78 | 79 | const eventStream = new EventStream('http://hostname:8080/path', 'token'); 80 | 81 | return eventStream.connect().then(() => { 82 | throw new Error('expected to throw error'); 83 | }, (reason) => { 84 | expect(reason).to.eql({ 85 | statusCode: 500, 86 | errorDescription: 'HTTP error 500 from http://hostname:8080/path', 87 | body: { 88 | error: 'unknown' 89 | } 90 | }); 91 | }); 92 | }); 93 | }); 94 | 95 | describe('parse', () => { 96 | let eventStream; 97 | beforeEach(() => { 98 | eventStream = new EventStream(); 99 | sinon.stub(eventStream, 'parseEventStreamLine'); 100 | }); 101 | 102 | it('accumulates date into the buffer before parsing line', () => { 103 | eventStream.parse('wo'); 104 | eventStream.parse('rd'); 105 | 106 | expect(eventStream.buf).to.eql('word'); 107 | expect(eventStream.parseEventStreamLine).not.to.have.been.called; 108 | }); 109 | 110 | it('parses a line ending with \\n', () => { 111 | const line = 'field: value\n'; 112 | 113 | eventStream.parse(line); 114 | 115 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line.indexOf(':'), line.indexOf('\n')); 116 | }); 117 | 118 | it('parses 2 lines ending with \\n', () => { 119 | const line1 = 'field: value\n'; 120 | const line2 = 'field2: value2\n'; 121 | 122 | eventStream.parse(line1 + line2); 123 | 124 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line1.indexOf(':'), line1.indexOf('\n')); 125 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(line1.length, line2.indexOf(':'), line2.indexOf('\n')); 126 | }); 127 | 128 | 129 | it('parses a line ending with \\r\\n', () => { 130 | const line = 'field: value\r\n'; 131 | 132 | eventStream.parse(line); 133 | 134 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line.indexOf(':'), line.indexOf('\r')); 135 | }); 136 | 137 | it('parses 2 lines ending with \\r\\n', () => { 138 | const line1 = 'field: value\r\n'; 139 | const line2 = 'field2: value2\r\n'; 140 | 141 | eventStream.parse(line1 + line2); 142 | 143 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line1.indexOf(':'), line1.indexOf('\r')); 144 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(line1.length, line2.indexOf(':'), line2.indexOf('\r')); 145 | }); 146 | 147 | it('clears buffer after parsing full lines', () => { 148 | eventStream.parse('field: value\n'); 149 | 150 | expect(eventStream.buf).to.eql(''); 151 | }); 152 | 153 | it('keeps partial lines in buffer after parsing full lines', () => { 154 | eventStream.parse('field: value\nfield2'); 155 | 156 | expect(eventStream.buf).to.eql('field2'); 157 | }); 158 | }); 159 | 160 | describe('parseEventStreamLine', () => { 161 | let eventStream; 162 | beforeEach(() => { 163 | eventStream = new EventStream(); 164 | }); 165 | 166 | it('ignores comments', () => { 167 | // comments starts with : at column 0 168 | const line = ':ok\n'; 169 | eventStream.buf = line; 170 | 171 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 172 | 173 | expect(eventStream.event).not.to.be.ok; 174 | expect(eventStream.data).to.be.eql(''); 175 | }); 176 | 177 | it('saves event name', () => { 178 | const line = 'event: testevent\n'; 179 | eventStream.buf = line; 180 | 181 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 182 | 183 | expect(eventStream.event).to.be.true; 184 | expect(eventStream.eventName).to.eql('testevent'); 185 | }); 186 | 187 | it('saves event data', () => { 188 | const line = 'data: {"data":"test"}\n'; 189 | eventStream.buf = line; 190 | 191 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 192 | 193 | expect(eventStream.data).to.eql('{"data":"test"}\n'); 194 | }); 195 | 196 | it('saves event name and data on separate lines', () => { 197 | const lines = ['event: testevent\n', 'data: {"data":"test"}\n']; 198 | for (let line of lines) { 199 | eventStream.buf = line; 200 | 201 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 202 | } 203 | 204 | expect(eventStream.event).to.be.true; 205 | expect(eventStream.eventName).to.eql('testevent'); 206 | expect(eventStream.data).to.eql('{"data":"test"}\n'); 207 | }); 208 | 209 | it('emits event on blank line after saving event name and data', () => { 210 | const handler = sinon.spy(); 211 | eventStream.event = true; 212 | eventStream.eventName = 'testevent'; 213 | eventStream.data = '{"data":"test"}\n'; 214 | eventStream.on('event', handler); 215 | 216 | eventStream.parseEventStreamLine(0, -1, 0); 217 | 218 | expect(handler).to.have.been.calledWith({ 219 | name: 'testevent', 220 | data: 'test' 221 | }); 222 | }); 223 | 224 | it('emits error if the event handler crashes', () => { 225 | const errorHandler = sinon.spy(); 226 | eventStream.event = true; 227 | eventStream.eventName = 'testevent'; 228 | eventStream.data = '{"data":"test"}\n'; 229 | eventStream.on('error', errorHandler); 230 | eventStream.on('event', () => { 231 | throw new Error('failed!'); 232 | }); 233 | 234 | eventStream.parseEventStreamLine(0, -1, 0); 235 | 236 | expect(errorHandler).to.have.been.called; 237 | }); 238 | 239 | it('clears the event after emitting it', () => { 240 | eventStream.event = true; 241 | eventStream.eventName = 'testevent'; 242 | eventStream.data = '{"data":"test"}\n'; 243 | 244 | eventStream.parseEventStreamLine(0, -1, 0); 245 | 246 | expect(eventStream.event).to.be.false; 247 | expect(eventStream.eventName).to.be.undefined; 248 | expect(eventStream.data).to.eql(''); 249 | }); 250 | 251 | it('ignores multiple blank lines in succession', () => { 252 | const handler = sinon.spy(); 253 | eventStream.on('event', handler); 254 | 255 | eventStream.parseEventStreamLine(0, -1, 0); 256 | eventStream.parseEventStreamLine(0, -1, 0); 257 | eventStream.parseEventStreamLine(0, -1, 0); 258 | 259 | expect(handler).not.to.have.been.called; 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /test/FakeAgent.js: -------------------------------------------------------------------------------- 1 | class FakeAgent { 2 | get({ uri, auth, headers, query, context }){ 3 | return this.request({ uri, method: 'get', auth, headers, query, context }); 4 | } 5 | 6 | head({ uri, auth, headers, query, context }){ 7 | return this.request({ uri, method: 'head', auth, headers, query, context }); 8 | } 9 | 10 | post({ uri, headers, data, auth, context }){ 11 | return this.request({ uri, method: 'post', auth, headers, data, context }); 12 | } 13 | 14 | put({ uri, auth, headers, data, query, context }){ 15 | return this.request({ uri, method: 'put', auth, headers, data, query, context }); 16 | } 17 | 18 | delete({ uri, auth, headers, data, context }){ 19 | return this.request({ uri, method: 'delete', auth, headers, data, context }); 20 | } 21 | 22 | request(opts){ 23 | return Promise.resolve(opts); 24 | } 25 | } 26 | module.exports = FakeAgent; 27 | -------------------------------------------------------------------------------- /test/Library.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('./test-setup'); 2 | const Library = require('../src/Library'); 3 | 4 | let client = {}; 5 | 6 | 7 | describe('Library', () => { 8 | describe('constructor', () => { 9 | it('sets attributes', () => { 10 | const library = new Library(client, { 11 | attributes: { 12 | name: 'testlib', 13 | version: '1.0.0' 14 | } 15 | }); 16 | expect(library.name).to.equal('testlib'); 17 | expect(library.version).to.equal('1.0.0'); 18 | }); 19 | }); 20 | 21 | describe('download', () => { 22 | it('return the file contents', () => { 23 | client.downloadFile = (url) => { 24 | return Promise.resolve(`${url}-content`); 25 | }; 26 | 27 | const library = new Library(client, { 28 | attributes: { 29 | name: 'testlib', 30 | version: '1.0.0' 31 | }, 32 | links: { 33 | download: 'url' 34 | } 35 | }); 36 | expect(library.download()).to.eventually.equal('url-content'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/Particle.integration.js: -------------------------------------------------------------------------------- 1 | const { expect, sinon } = require('./test-setup'); 2 | const Particle = require('../src/Particle'); 3 | 4 | describe('Particle', () => { 5 | let api; 6 | 7 | beforeEach(() => { 8 | api = new Particle({ baseUrl: '' }); 9 | }); 10 | 11 | describe('downloadFile', () => { 12 | it('download the file', () => { 13 | const uri = 'https://binaries.particle.io/libraries/neopixel/neopixel-0.0.10.tar.gz'; 14 | const fileSize = 25505; 15 | return api.downloadFile({ uri }) 16 | .then(contents => { 17 | expect(contents.length || contents.byteLength).to.equal(fileSize); 18 | }); 19 | }); 20 | }); 21 | 22 | describe('context', () => { 23 | it('adds headers for the context', () => { 24 | api.setContext('tool', { name:'cli', version:'1.2.3' }); 25 | api.setContext('project', { name:'blinky', version:'0.0.1' }); 26 | api.agent._promiseResponse = sinon.stub().resolves(); 27 | return api.flashTinker('deviceID', 'auth').then(() => { 28 | expect(api.agent._promiseResponse).to.have.been.calledOnce; 29 | const req = api.agent._promiseResponse.firstCall.args[0]; 30 | const options = req[1]; 31 | expect(req).to.be.ok; 32 | expect(options.headers).to.have.property('X-Particle-Tool').eql('cli@1.2.3'); 33 | expect(options.headers).to.have.property('X-Particle-Project').eql('blinky; version=0.0.1'); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | 2 | const fixtures = { 3 | 'libraries.json': require('./libraries.json'), 4 | 'library.json': require('./library.json'), 5 | 'libraryVersions.json': require('./libraryVersions.json') 6 | }; 7 | 8 | function read(filename) { 9 | if (!fixtures[filename]) { 10 | throw new Error(`Fixture ${filename} doesn't exit`); 11 | } 12 | return fixtures[filename]; 13 | } 14 | 15 | module.exports = { read }; 16 | -------------------------------------------------------------------------------- /test/fixtures/libraries.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "libraries", 5 | "id": "neopixel", 6 | "links": { 7 | "download": "https://binaries.particle.io/libraries/neopixel/neopixel-0.0.10.tar.gz" 8 | }, 9 | "attributes": { 10 | "name": "neopixel", 11 | "version": "0.0.10", 12 | "license": "GNU GPLv3", 13 | "author": "Phil Burgess / Paint Your Dragon for Adafruit Industries", 14 | "maintainer": "Brett Walach ", 15 | "sentence": "Neopixel LED library", 16 | "paragraph": "An Implementation of Adafruit's NeoPixel Library for the Spark Core, Particle Photon, P1, Electron and RedBear Duo", 17 | "category": "Other", 18 | "url": "https://github.com/technobly/SparkCore-NeoPixel", 19 | "repository": "technobly/SparkCore-NeoPixel", 20 | "architectures": [ 21 | "avr", 22 | "spark-core", 23 | "particle-photon", 24 | "particle-p1", 25 | "particle-electron", 26 | "redbear-duo" 27 | ], 28 | "dependencies": {} 29 | }, 30 | "relationships": {} 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /test/fixtures/library.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "libraries", 4 | "id": "neopixel", 5 | "links": { 6 | "download": "https://binaries.particle.io/libraries/neopixel/neopixel-0.0.10.tar.gz" 7 | }, 8 | "attributes": { 9 | "name": "neopixel", 10 | "version": "0.0.10", 11 | "license": "GNU GPLv3", 12 | "author": "Phil Burgess / Paint Your Dragon for Adafruit Industries", 13 | "maintainer": "Brett Walach ", 14 | "sentence": "Neopixel LED library", 15 | "paragraph": "An Implementation of Adafruit's NeoPixel Library for the Spark Core, Particle Photon, P1, Electron and RedBear Duo", 16 | "category": "Other", 17 | "url": "https://github.com/technobly/SparkCore-NeoPixel", 18 | "repository": "technobly/SparkCore-NeoPixel", 19 | "architectures": [ 20 | "avr", 21 | "spark-core", 22 | "particle-photon", 23 | "particle-p1", 24 | "particle-electron", 25 | "redbear-duo" 26 | ], 27 | "dependencies": {} 28 | }, 29 | "relationships": {} 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/fixtures/libraryVersions.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "libraries", 5 | "id": "neopixel", 6 | "links": { 7 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.10.tar.gz" 8 | }, 9 | "attributes": { 10 | "name": "neopixel", 11 | "version": "0.0.10", 12 | "installs": 7385, 13 | "license": "GNU GPLv3", 14 | "author": "Adafruit, Technobly", 15 | "maintainer": null, 16 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core, Particle Photon, P1, Electron and RedBear Duo", 17 | "paragraph": null, 18 | "category": null, 19 | "url": null, 20 | "repository": null, 21 | "architectures": [], 22 | "dependencies": null, 23 | "build": null 24 | } 25 | }, 26 | { 27 | "type": "libraries", 28 | "id": "neopixel", 29 | "links": { 30 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.9.tar.gz" 31 | }, 32 | "attributes": { 33 | "name": "neopixel", 34 | "version": "0.0.9", 35 | "installs": 7385, 36 | "license": "GNU GPLv3", 37 | "author": "Adafruit, Technobly", 38 | "maintainer": null, 39 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core, Photon, P1 and Electron", 40 | "paragraph": null, 41 | "category": null, 42 | "url": null, 43 | "repository": null, 44 | "architectures": [], 45 | "dependencies": null, 46 | "build": null 47 | } 48 | }, 49 | { 50 | "type": "libraries", 51 | "id": "neopixel", 52 | "links": { 53 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.8.tar.gz" 54 | }, 55 | "attributes": { 56 | "name": "neopixel", 57 | "version": "0.0.8", 58 | "installs": 7385, 59 | "license": "GNU GPLv3", 60 | "author": "Adafruit, Technobly", 61 | "maintainer": null, 62 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core, Photon, P1 and Electron", 63 | "paragraph": null, 64 | "category": null, 65 | "url": null, 66 | "repository": null, 67 | "architectures": [], 68 | "dependencies": null, 69 | "build": null 70 | } 71 | }, 72 | { 73 | "type": "libraries", 74 | "id": "neopixel", 75 | "links": { 76 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.7.tar.gz" 77 | }, 78 | "attributes": { 79 | "name": "neopixel", 80 | "version": "0.0.7", 81 | "installs": 7385, 82 | "license": "GNU GPLv3", 83 | "author": "Adafruit, Technobly", 84 | "maintainer": null, 85 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core and Photon", 86 | "paragraph": null, 87 | "category": null, 88 | "url": null, 89 | "repository": null, 90 | "architectures": [], 91 | "dependencies": null, 92 | "build": null 93 | } 94 | }, 95 | { 96 | "type": "libraries", 97 | "id": "neopixel", 98 | "links": { 99 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.6.tar.gz" 100 | }, 101 | "attributes": { 102 | "name": "neopixel", 103 | "version": "0.0.6", 104 | "installs": 7385, 105 | "license": "GNU GPLv3", 106 | "author": "Adafruit, Technobly", 107 | "maintainer": null, 108 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core and Photon", 109 | "paragraph": null, 110 | "category": null, 111 | "url": null, 112 | "repository": null, 113 | "architectures": [], 114 | "dependencies": null, 115 | "build": null 116 | } 117 | }, 118 | { 119 | "type": "libraries", 120 | "id": "neopixel", 121 | "links": { 122 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.5.tar.gz" 123 | }, 124 | "attributes": { 125 | "name": "neopixel", 126 | "version": "0.0.5", 127 | "installs": 7385, 128 | "license": "GNU GPLv3", 129 | "author": "Adafruit, Technobly", 130 | "maintainer": null, 131 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core", 132 | "paragraph": null, 133 | "category": null, 134 | "url": null, 135 | "repository": null, 136 | "architectures": [], 137 | "dependencies": null, 138 | "build": null 139 | } 140 | }, 141 | { 142 | "type": "libraries", 143 | "id": "neopixel", 144 | "links": { 145 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.4.tar.gz" 146 | }, 147 | "attributes": { 148 | "name": "neopixel", 149 | "version": "0.0.4", 150 | "installs": 7385, 151 | "license": "GNU GPLv3", 152 | "author": "Adafruit, Technobly", 153 | "maintainer": null, 154 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core", 155 | "paragraph": null, 156 | "category": null, 157 | "url": null, 158 | "repository": null, 159 | "architectures": [], 160 | "dependencies": null, 161 | "build": null 162 | } 163 | }, 164 | { 165 | "type": "libraries", 166 | "id": "neopixel", 167 | "links": { 168 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.3.tar.gz" 169 | }, 170 | "attributes": { 171 | "name": "neopixel", 172 | "version": "0.0.3", 173 | "installs": 7385, 174 | "license": "GNU GPLv3", 175 | "author": "Adafruit, Technobly", 176 | "maintainer": null, 177 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core", 178 | "paragraph": null, 179 | "category": null, 180 | "url": null, 181 | "repository": null, 182 | "architectures": [], 183 | "dependencies": null, 184 | "build": null 185 | } 186 | }, 187 | { 188 | "type": "libraries", 189 | "id": "neopixel", 190 | "links": { 191 | "download": "https://library-archives.particle.io/libraries/neopixel/neopixel-0.0.2.tar.gz" 192 | }, 193 | "attributes": { 194 | "name": "neopixel", 195 | "version": "0.0.2", 196 | "installs": 7385, 197 | "license": "GNU GPLv3", 198 | "author": "Adafruit, Technobly", 199 | "maintainer": null, 200 | "sentence": "An Implementation of Adafruit's NeoPixel Library for the Spark Core", 201 | "paragraph": null, 202 | "category": null, 203 | "url": null, 204 | "repository": null, 205 | "architectures": [], 206 | "dependencies": null, 207 | "build": null 208 | } 209 | } 210 | ] 211 | } 212 | -------------------------------------------------------------------------------- /test/out.tmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/particle-api-js/e5535d1fe988dfa5e53c990b953ed88871d90260/test/out.tmp -------------------------------------------------------------------------------- /test/support/FixtureHttpServer.js: -------------------------------------------------------------------------------- 1 | // Serve files from the fixture folder 2 | const express = require('express'); 3 | const fixtures = require('../fixtures'); 4 | 5 | 6 | class FixtureHttpServer { 7 | constructor(){ 8 | this.app = express(); 9 | this.app.get('/:filename', (req, res) => { 10 | res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); 11 | res.end(fixtures.read(req.params['filename']), 'binary'); 12 | }); 13 | } 14 | 15 | // Call in a before() test hook 16 | listen(){ 17 | return new Promise(fulfill => { 18 | this.server = this.app.listen(0, fulfill); 19 | }); 20 | } 21 | 22 | url(){ 23 | return `http://localhost:${this.server.address().port}`; 24 | } 25 | } 26 | 27 | module.exports = FixtureHttpServer; 28 | -------------------------------------------------------------------------------- /test/test-setup.js: -------------------------------------------------------------------------------- 1 | // Set up the Mocha test framework with the Chai assertion library and 2 | // the Sinon mock library 3 | 4 | const chai = require('chai'); 5 | const sinon = require('sinon'); 6 | const sinonChai = require('sinon-chai'); 7 | const chaiAsPromised = require('chai-as-promised'); 8 | 9 | chai.use(sinonChai); 10 | chai.use(chaiAsPromised); 11 | const expect = chai.expect; 12 | 13 | module.exports = { 14 | chai, 15 | sinon, 16 | expect 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "noImplicitAny": false 12 | }, 13 | "types": ["node"], 14 | "include": [ "src" ], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | 5 | module.exports = (env) => { 6 | return { 7 | mode: env.mode, 8 | target: 'web', 9 | entry: './src/Particle.js', 10 | devtool: 'source-map', 11 | output: { 12 | filename: `particle${env.mode === 'production' ? '.min' : ''}.js`, 13 | path: path.resolve(__dirname, 'dist'), 14 | clean: true, 15 | library: { 16 | name: 'Particle', 17 | type: 'var' 18 | } 19 | }, 20 | optimization: { 21 | minimize: env.mode === 'production', 22 | minimizer: [new TerserPlugin({ 23 | extractComments: false, 24 | terserOptions: { 25 | format: { 26 | comments: false 27 | } 28 | } 29 | })] 30 | }, 31 | resolve: { 32 | fallback: { 33 | buffer: require.resolve('buffer'), 34 | events: require.resolve('events'), 35 | url: require.resolve('url') 36 | } 37 | }, 38 | plugins: [ 39 | new webpack.ProvidePlugin({ 40 | Buffer: ['buffer', 'Buffer'], 41 | process: 'process/browser', 42 | }) 43 | ] 44 | }; 45 | }; 46 | --------------------------------------------------------------------------------