├── .circleci └── config.yml ├── .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 ├── eslint.config.mjs ├── 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 ├── 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@2.2.0 5 | 6 | jobs: 7 | lint: 8 | docker: 9 | - image: cimg/node:22.18 # eslint 9.x requires 18+, so we've separated it from the test step 10 | auth: 11 | username: $DOCKERHUB_USERNAME 12 | password: $DOCKERHUB_PASSWORD 13 | steps: 14 | - checkout 15 | - run: 16 | name: NPM install 17 | command: npm ci 18 | - run: 19 | name: Lint 20 | command: npm run lint 21 | run-tests: 22 | parameters: 23 | node-version: 24 | type: string 25 | docker: 26 | - image: cimg/node:<< parameters.node-version >>-browsers # Primary execution image 27 | auth: 28 | username: $DOCKERHUB_USERNAME 29 | password: $DOCKERHUB_PASSWORD 30 | steps: 31 | - checkout 32 | - run: 33 | name: NPM install 34 | command: npm ci 35 | - run: 36 | name: Run tests with coverage 37 | command: npm run test:ci 38 | - when: 39 | condition: 40 | equal: ["16.20.0", << parameters.node-version >>] 41 | steps: 42 | - browser-tools/install_browser_tools 43 | - run: 44 | name: Run tests with browser 45 | command: npm run test:browser 46 | environment: 47 | # Currently this test fails in CircleCI. Skip to unblock release 48 | SKIP_AGENT_TEST: true 49 | publish-npm: 50 | docker: 51 | - image: cimg/node:16.20.0 # Primary execution image 52 | auth: 53 | username: $DOCKERHUB_USERNAME 54 | password: $DOCKERHUB_PASSWORD 55 | steps: 56 | - checkout 57 | - run: 58 | name: NPM install 59 | command: npm ci 60 | - run: 61 | name: Authenticate with NPM 62 | command: npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" 63 | - run: 64 | name: Publish package 65 | command: | 66 | # Publish as beta for pre-release tags like v1.2.3-pre.1 67 | [[ $CIRCLE_TAG =~ ^v.*- ]] && NPM_TAG=--tag=beta 68 | npm publish $NPM_TAG 69 | 70 | workflows: 71 | version: 2 72 | test-and-publish: 73 | jobs: 74 | - lint: 75 | context: 76 | - particle-ci-private 77 | # run tests for all branches 78 | filters: 79 | branches: 80 | only: /.*/ 81 | - run-tests: 82 | context: 83 | - particle-ci-private 84 | matrix: 85 | parameters: 86 | node-version: ["16.20.0", "22.18"] 87 | # run tests for all branches and tags 88 | filters: 89 | tags: 90 | only: /^v.*/ 91 | branches: 92 | only: /.*/ 93 | - publish-npm: 94 | requires: 95 | - lint 96 | - run-tests 97 | context: 98 | - particle-ci-private 99 | # publish for tags only 100 | filters: 101 | tags: 102 | only: /^v.*/ 103 | branches: 104 | ignore: /.*/ 105 | -------------------------------------------------------------------------------- /.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 | 22 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 11.1.7 - 06 October 2025 4 | * Add downloadManufacturingBackup method 5 | 6 | ## 11.1.6 - 20 September 2025 7 | * Fix request params to support `FormData` passed in the data argument 8 | * Update docs on queries receiving `data` 9 | * Update eslint configuration 10 | 11 | ## 11.1.5 - 19 September 2025 12 | * Ensure node 22 works as expected 13 | * Remove node 12 validations 14 | * Update eslint configuration 15 | 16 | ## 11.1.4 - 20 August 2025 17 | * Clarify license is Apache 2.0 18 | 19 | ## 11.1.3 - 22 July 2025 20 | * Bump form-data to resolve security warning 21 | 22 | ## 11.1.2 - 11 December 2024 23 | * Add context docs for x-particle-tool and x-particle-project headers 24 | 25 | ## 11.1.1 - 5 November 2024 26 | * Workaround for Firefox failing to open multiple event streams 27 | 28 | ## 11.1.0 - 3 October 2024 29 | * Re-add `deleteAccessToken` method, but with basic auth removed 30 | 31 | ## 11.0.0 - 1 October 2024 32 | * Remove `listAccessTokens` and `deleteAccessToken` methods that relied on HTTP basic auth 33 | * Update EventStream to send access_token in the Authorization header instead. No functional change 34 | 35 | ## 10.6.0 - 21 August 2024 36 | * Add DeviceOS versions endpoints 37 | 38 | ## 10.5.1 - 21 June 2024 39 | * Don't add an empty query string to the URL 40 | 41 | ## 10.5.0 - 14 June 2024 42 | * Add `unprotectDevice` 43 | * Remove `changeProduct` 44 | 45 | ## 10.4.3 - 25 March 2024 46 | * Add `utm` paramater to `createUser` 47 | 48 | ## 10.4.2 - 3 January 2024 49 | * Add `setMode` arg to `setLedgerInstance` 50 | * Fix a few incorrect logic function JSDocs 51 | 52 | ## 10.4.1 - 2 January 2024 53 | 54 | * Fix `setLedgerInstance` taking wrong argument and passing a bad request body to the API 55 | 56 | ## 10.4.0 - 19 December 2023 57 | 58 | * Add `listLedgerInstanceVersions` function 59 | * Add `getLedgerInstanceVersion` function 60 | 61 | ## 10.3.1 - 6 December 2023 62 | 63 | * Add `todayStats` query option to listLogicFunctions 64 | * Add `scope`, `archived`, `page`, and `perPage` query options to listLedgers 65 | * Add `page` and `perPage` query options to listLedgerInstances 66 | * Add JSDocs for constructor 67 | * Fix JSDocs across most functions in the library to be more accurate 68 | 69 | ## 10.3.0 - 7 November 2023 70 | 71 | * Add support for sandbox accounts on all logic/ledger APIs by allowing the omission of the `org` property 72 | * Add `executeLogic` function for testing logic functions before deploying them 73 | 74 | ## 10.2.0 - 6 October 2023 75 | 76 | * Migrate LogicBlocks methods to LogicFunctions, LogicTriggers, and LogicRuns 77 | 78 | ## 10.1.0 - 8 Sept 2023 79 | 80 | * Use wepback to bundle for browser use 81 | * Remove babel, browserify and their related dependencies 82 | * Adds support for checkJS loose type/docs validations 83 | 84 | ## 10.0.0 - 8 Sept 2023 85 | 86 | * Change library to handle requests from `superagent` to `fetch`/`node-fetch` 87 | 88 | ## 9.4.1 - 17 June 2022 89 | 90 | * Fixes incompatible versions of `eslint`, `chai`, `sinon-chai` and `chai-as-promised`. 91 | 92 | ## 9.4.0 - 14 June 2022 93 | 94 | * Adds `.setDefaultAuth(auth)` so token authenticated methods don't need to pass their own auth token. 95 | * Adds support for `auth` option in Particle constructor, calls `.setDefaultAuth()` if provided 96 | * Fixes bug where `.setBaseUrl(baseUrl)` was not honored for `.getEventStream()` method (sc-105035) 97 | 98 | ## 9.3.0 - 8 June 2022 99 | * Adds `.setBaseUrl(baseUrl)` to override backend api endpoint 100 | 101 | ## 9.2.0 - 30 May 2022 102 | * Move to `node@16` and `npm@8` for local development 103 | 104 | ## 9.1.2 - 9 December 2021 105 | * Fix library download 106 | 107 | ## 9.1.1 - 7 December 2021 108 | * Use unforked copy of `stream-http` dependency 109 | 110 | ## 9.1.0 - 8 December 2020 111 | * `.listAccessTokens()` accepts `otp` option to support users with MFA enabled 112 | 113 | ## 9.0.2 - 28 July 2020 114 | * Add `.deleteActiveAccessTokens()` method 115 | * Add `invalidateTokens` arguments to `.confirmMfa()` and `.changeUsername()` methods 116 | 117 | ## 9.0.1 - 1 June 2020 118 | * Add `.getProductDeviceConfiguration()` and `.getProductDeviceConfigurationSchema()` methods 119 | 120 | ## 9.0.0 - 20 May 2020 121 | * Add support for configuration and location services 122 | * All top-level api methods optionally accept a `headers` option object 123 | * 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)) 124 | * Breaking: `.downloadFile()` method uses `uri` option (vs. `url`) ([docs](https://github.com/particle-iot/particle-api-js/blob/master/docs/api.md#downloadfile)) 125 | * 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)) 126 | 127 | ## 8.4.0 - 28 April 2020 128 | * Allow invalidating access tokens when changing password 129 | 130 | ## 8.3.0 - 11 February 2020 131 | * Add delete user 132 | 133 | ## 8.2.1 - 4 February 2020 134 | * fix file download methods `.downloadFile()`, `.downloadFirmwareBinary()`, and `.downloadProductFirmware()` [PR #112](https://github.com/particle-iot/particle-api-js/pull/112) 135 | 136 | ## 8.2.0 - 28 January 2020 137 | * `.addDeviceToProduct()` accepts `file` option to facillitate bulk importing of devices [PR #109](https://github.com/particle-iot/particle-api-js/pull/109) 138 | 139 | ## 8.1.0 - 24 January 2020 140 | * Add support for `groups` query parameter when listing product devices via `.listDevices()` [PR #108](https://github.com/particle-iot/particle-api-js/pull/108) 141 | * Update `eslint` and related configuration [PR #107](https://github.com/particle-iot/particle-api-js/pull/107) 142 | 143 | ## 8.0.1 - 2 December 2019 144 | * Update to latest superagent to fix deprecation warnings in Node v12 145 | 146 | ## 8.0.0 - 30 July 2019 147 | 148 | * EventStream returned by getEventStream handles errors better [PR #99](https://github.com/particle-iot/particle-api-js/pull/99). 149 | **Breaking changes for EventStream:** 150 | - 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. 151 | - Does not emit the 'error' event when a network error happens. Instead it emits 'disconnect' and automatically reconnects. 152 | 153 | ## 7.4.1 - 6 May 2019 154 | * Do not require network ID to remove a device from its network [PR #103](https://github.com/particle-iot/particle-api-js/pull/103) 155 | 156 | ## 7.4.0 - 27 Feb 2019 157 | * Add support for mesh network management [PR #98](https://github.com/particle-iot/particle-api-js/pull/98) 158 | 159 | ## 7.3.0 - 10 Jan 2019 160 | * Support flashing product devices [PR #97](https://github.com/particle-iot/particle-api-js/pull/97) 161 | 162 | ## 7.2.3 - 4 Aug 2018 163 | * 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) 164 | 165 | ## 7.2.2 - 23 Jul 2018 166 | * Fix npm api key for publishing to registry 167 | 168 | ## 7.2.1 - 23 Jul 2018 169 | * Support enrolling user in MFA/Two-step authentication 170 | 171 | ## 7.2.0 - 22 Mar 2018 172 | * Support changing user's username(i.e., email) and password [PR #84](https://github.com/particle-iot/particle-api-js/pull/84) 173 | 174 | ## 7.1.1 - 13 Feb 2018 175 | * Fix country parameter for activate sim [PR #81](https://github.com/particle-iot/particle-api-js/pull/81) 176 | 177 | ## 7.1.0 - 17 Jan 2018 178 | 179 | * Update jsDelivr link [PR #66](https://github.com/particle-iot/particle-api-js/pull/66). Thanks @LukasDrgon! 180 | * Stop auto reconnecting when event stream is intentionally disconnected [PR #69](https://github.com/particle-iot/particle-api-js/pull/69). Thanks @spacetc62! 181 | * Add createCustomer [PR #78](https://github.com/particle-iot/particle-api-js/pull/78). Thanks @monkeytronics! 182 | * Fix event stream exception when it is an HTML response [PR #64](https://github.com/particle-iot/particle-api-js/pull/64). Thanks @spacetc62! 183 | * Update links after GitHub organization rename to `particle-iot` [PR #79](https://github.com/particle-iot/particle-api-js/pull/79) 184 | 185 | ## 7.0.1 - 16 Nov 2017 186 | * Add loginAsClientOwner method 187 | 188 | ## 7.0.0 - 7 Nov 2017 189 | * Update to latest superagent with support for nested directory. **Drops support for Node versions earlier than 4.** 190 | * Add serial number endpoint 191 | 192 | ## 6.6.2 - 15 Sep 2017 193 | * Fix nested directories bug 194 | 195 | ## 6.6.1 - 14 Sep 2017 196 | * Update form-data to v1.0.0-relativepath.2 197 | 198 | ## 6.6.0 - 12 Sep 2017 199 | 200 | * Add support for deleting current token 201 | 202 | ## 6.5.0 - 02 May 2017 203 | 204 | * Add support for all product API endpoints. 205 | * Add support for sending additional context with each call. 206 | 207 | ## 6.4.3 - 15 Feb 2017 208 | 209 | * Create a wrapper for `listBuildTargets` in `Client.js`. 210 | * Marked `compileCode`, `signalDevice`, `listDevices` and `listBuildTargets` as deprecated. Those methods will be removed in 6.5 211 | 212 | ## 6.4.2 - 05 Jan 2017 213 | 214 | * Create a wrapper for `listDevices` in `Client.js`. 215 | 216 | ## 6.4.1 - 15 Dec 2016 217 | 218 | * Add scopes to library listing 219 | 220 | ## 6.4.0 - 09 Nov 2016 221 | 222 | * Create a wrapper for `signalDevice` in `Client.js`. 223 | 224 | ## 6.3.0 - 31 Oct 2016 225 | 226 | * Add support for account verification endpoint via verifyUser function 227 | * Change account_info input parameter in createUser and setUserInfo to be camel case - accountInfo 228 | 229 | ## 6.2.0 - 19 Oct 2016 230 | 231 | * Add support for account information fields in createUser and setUserInfo 232 | * Add "shortErrorDescription" in response body to contain English description only 233 | 234 | ## 6.1.0 - 19 Oct 2016 235 | 236 | * Add library publish 237 | 238 | ## 6.0.8 - 17 Oct 2016 239 | 240 | * Rename library publish to library contribute 241 | 242 | ## 6.0.7 - 29 Sept 2016 243 | 244 | * Add library versions endpoint 245 | 246 | ## 6.0.6 - 19 Sept 2016 247 | 248 | * Add library delete 249 | 250 | ## 6.0.5 - 8 Sept 2016 251 | 252 | * Add library publish 253 | 254 | ## 6.0.4 - 30 Aug 2016 255 | 256 | * Use only HTTP dependencies to be able to install on computers without git 257 | 258 | ## 6.0.3 - 25 Aug 2016 259 | 260 | * Support nested directories when compiling sources 261 | 262 | ## 6.0.2 - 23 Aug 2016 263 | 264 | * Add compile code to client 265 | 266 | ## 6.0.1 - 22 Aug 2016 267 | 268 | * Fix the login method content type 269 | 270 | ## 6.0.0 - 17 Aug 2016 271 | 272 | * Add libraries endpoints 273 | * Add stateful client 274 | * Add object interface for libraries 275 | 276 | ## 5.3.1 - 2 Aug 2016 277 | 278 | * Handle empty event names in the event stream. 279 | 280 | ## 5.3.0 - 8 June 2016 281 | 282 | * Add details to README 283 | * Adding responseTemplate and responseTopic to webhook creation. Thanks @acasas! [#20](https://github.com/particle-iot/particle-api-js/pull/20) 284 | * Add password reset route [#27](https://github.com/particle-iot/particle-api-js/pull/27) 285 | * Make event stream compatible with new product routes [#28](https://github.com/particle-iot/particle-api-js/pull/28) 286 | 287 | ## 5.2.7 - 2 May 2016 288 | 289 | * Fix files parameter default name to be `file` and not `file1`. 290 | 291 | ## 5.2.6 - 25 Mar 2016 292 | 293 | * Don't double publish event stream events if the event is named `event`. 294 | 295 | ## 5.2.5 - 21 Mar 2016 296 | 297 | * Handle `JSON.parse` exceptions when parsing event stream 298 | 299 | ## 5.2.4 - 21 Mar 2016 300 | 301 | * `flashDevice` `latest` also needs to be a string, not a boolean. [#12](https://github.com/particle-iot/particle-api-js/issues/12) 302 | 303 | ## 5.2.3 - 11 Mar 2016 304 | 305 | * Remove setting of `User-Agent` header because that is not allowed in browsers. [#10](https://github.com/particle-iot/particle-api-js/issues/10) 306 | 307 | ## 5.2.2 - 3 Mar 2016 308 | 309 | * Fix named event streams by encoding event name. 310 | * Move access token to query string to eliminate preflight CORS request. 311 | * Use fork of `stream-http` that prevents usage of `fetch` because it does not abort. 312 | * Use correct streaming mode of `stream-http`. 313 | 314 | ## 5.2.1 - 3 Mar 2016 315 | 316 | * Improve cleanup on `abort`. 317 | 318 | ## 5.2.0 - 3 Mar 2016 319 | 320 | * Add support for organization and product slugs to `getEventStream`. 321 | 322 | ## 5.1.1 - 26 Feb 2016 323 | 324 | * `JSON.parse` HTTP response body for `getEventStream` error case. 325 | 326 | ## 5.1.0 - 26 Feb 2016 327 | 328 | * Fix event stream. [#8](https://github.com/particle-iot/particle-api-js/issues/8) 329 | * Add `downloadFirmwareBinary` 330 | * Add ability to intercept requests for debugging 331 | * Use library version for User-Agent 332 | * Allow request transfer for `claimDevice` 333 | * `signalDevice` needs to use strings, not numbers. 334 | * `compileCode` `latest` should be a string, not a boolean. 335 | 336 | ## 5.0.2 - 24 Feb 2016 337 | 338 | * Remove trailing slash from `baseUrl`. [#7](https://github.com/particle-iot/particle-api-js/issues/7) 339 | 340 | ## 5.0.1 - 18 Feb 2016 341 | 342 | * Remove need for `require('particle-api-js').default` in CommonJS usage. It is now just `require('particle-api-js')`. 343 | 344 | ## 5.0.0 - 18 Feb 2016 345 | 346 | * Removed need for `babel-runtime`. 347 | * Add `flashDevice`, `compileCode`, and `listAccessTokens`. 348 | * Add missing options to `createWebhook`. 349 | * Remove `downloadFirmwareBinary`. 350 | 351 | ## 4.2.1 - 8 Feb 2016 352 | 353 | * Update contributors. 354 | 355 | ## 4.2.0 - 8 Feb 2016 356 | 357 | * Add `downloadFirmwareBinary`. 358 | 359 | ## 4.1.0 - 14 Jan 2016 360 | 361 | * Add `validatePromoCode`. 362 | * `activateSIM` now requires `promo_code` and `action`. 363 | 364 | ## 4.0.2 - 16 Nov 2015 365 | 366 | * Fix old `code` reference. 367 | 368 | ## 4.0.1 - 16 Nov 2015 369 | 370 | * Change `code` to `statusCode` in rejection. 371 | 372 | ## 4.0.0 - 16 Nov 2015 373 | 374 | * Add `statusCode` to Promise fulfillment. 375 | 376 | ## 3.0.3 - 6 Nov 2015 377 | 378 | * Add `listBuildTargets`. 379 | 380 | ## 3.0.2 - 5 Nov 2015 381 | 382 | * Add `countryCode` to `activateSIM`. 383 | 384 | ## 3.0.1 - 26 Oct 2015 385 | 386 | * Fix `activateSIM`. 387 | 388 | ## 3.0.0 - 26 Oct 2015 389 | 390 | * Replace `request` with `superagent`. 391 | * Add `iccid` to `getClaimCode`. 392 | * Only use form encoding on `login` and `signup`. 393 | 394 | ## 2.0.1 - 23 Oct 2015 395 | 396 | * 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. 397 | 398 | ## 2.0.0 - 20 Oct 2015 399 | 400 | * 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. 401 | 402 | ## 1.0.1 - 24 Sep 2015 403 | 404 | ## 1.0.0 - 24 Sep 2015 405 | -------------------------------------------------------------------------------- /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 | 'use strict'; 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 2025 Particle Industries, Inc. 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 | 'use strict'; 3 | module.exports = require('fs'); 4 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Jul 20 2016 12:00:09 GMT-0400 (EDT) 3 | 'use strict'; 4 | const webpackConf = require('./webpack.config.js'); 5 | const webpack = require('webpack'); 6 | 7 | module.exports = function karmaCfg(config){ 8 | config.set({ 9 | // base path that will be used to resolve all patterns (eg. files, exclude) 10 | basePath: '', 11 | 12 | // frameworks to use 13 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 14 | frameworks: ['webpack', 'mocha', 'chai'], 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'dist/particle.min.js', 19 | 'test/*.spec.js', 20 | 'test/*.integration.js' 21 | ], 22 | 23 | // list of files to exclude 24 | exclude: [ 25 | ], 26 | 27 | // preprocess matching files before serving them to the browser 28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 29 | preprocessors: { 30 | 'src/**/*.js': ['webpack'], 31 | 'test/**/*.js': ['webpack'] 32 | }, 33 | 34 | // Transform test files to a single browser consumable file 35 | webpack: { 36 | mode: 'development', 37 | target: 'web', 38 | devtool: 'inline-source-map', 39 | output: webpackConf.output, 40 | externals: webpackConf.externals, 41 | resolve: webpackConf.resolve, 42 | plugins: [ 43 | new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), 44 | new webpack.EnvironmentPlugin({ 45 | SKIP_AGENT_TEST: process.env.SKIP_AGENT_TEST || false 46 | }) 47 | ] 48 | }, 49 | 50 | // test results reporter to use 51 | // possible values: 'dots', 'progress' 52 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 53 | reporters: ['progress', 'coverage'], 54 | 55 | // web server port 56 | port: 9876, 57 | 58 | // enable / disable colors in the output (reporters and logs) 59 | colors: true, 60 | 61 | // level of logging 62 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 63 | logLevel: config.LOG_INFO, 64 | 65 | // enable / disable watching file and executing tests whenever any file changes 66 | autoWatch: true, 67 | 68 | // start these browsers 69 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 70 | browsers: ['Firefox'], 71 | 72 | // Continuous Integration mode 73 | // if true, Karma captures browsers, runs the tests and exits 74 | singleRun: false, 75 | 76 | // Concurrency level 77 | // how many browser should be started simultaneous 78 | concurrency: Infinity 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "particle-api-js", 3 | "version": "11.1.7", 4 | "description": "Particle API Client", 5 | "main": "src/Particle.js", 6 | "scripts": { 7 | "prepare": "npm run build", 8 | "test": "npm run lint && npm run typecheck && npm run test:unit", 9 | "test:ci": "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", 15 | "coverage": "nyc --reporter=text --include='src/**/*.js' --temp-dir=./tmp/ --check-coverage --lines 91 npm run test:unit:silent", 16 | "lint": "eslint", 17 | "docs": "documentation build src/Particle.js --shallow -g -f md -o docs/api.md", 18 | "build": "webpack --env mode=production", 19 | "build-nomin": "webpack --env mode=development", 20 | "preversion": "npm run test && npm run prepare", 21 | "reinstall": "rm -rf ./node_modules && npm i", 22 | "version": "npm run build && npm run docs && npm run update-changelog && git add dist/* docs/*", 23 | "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" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/particle-iot/particle-api-js" 28 | }, 29 | "author": "Julien Vanier ", 30 | "contributors": [ 31 | "Ido Kleinman", 32 | "Bryce Kahle", 33 | "Justin Debbink", 34 | "Matthew McGowan", 35 | "Julien Vanier", 36 | "Wojtek Siudzinski", 37 | "Emily Rose" 38 | ], 39 | "keywords": [ 40 | "particle", 41 | "library", 42 | "spark", 43 | "api" 44 | ], 45 | "license": "Apache-2.0", 46 | "dependencies": { 47 | "form-data": "^4.0.0", 48 | "node-fetch": "^2.7.0", 49 | "qs": "^6.11.2", 50 | "stream-http": "^3.2.0" 51 | }, 52 | "devDependencies": { 53 | "@types/node": "^20.5.9", 54 | "buffer": "^6.0.3", 55 | "chai": "^4.3.6", 56 | "chai-as-promised": "^7.1.1", 57 | "documentation": "^4.0.0-rc.1", 58 | "eslint": "^9.36.0", 59 | "eslint-config-particle": "^3.0.0", 60 | "events": "^3.3.0", 61 | "karma": "^6.4.4", 62 | "karma-chai": "^0.1.0", 63 | "karma-cli": "^2.0.0", 64 | "karma-coverage": "^2.2.1", 65 | "karma-firefox-launcher": "^2.1.3", 66 | "karma-mocha": "^2.0.1", 67 | "karma-webpack": "^5.0.1", 68 | "mocha": "^2.5.1", 69 | "nyc": "^15.1.0", 70 | "process": "^0.11.10", 71 | "should": "^9.0.0", 72 | "sinon": "^7.2.5", 73 | "sinon-chai": "^3.7.0", 74 | "terser-webpack-plugin": "^5.3.9", 75 | "typescript": "^5.9.2", 76 | "url": "^0.11.3", 77 | "webpack": "^5.88.2", 78 | "webpack-cli": "^5.1.4" 79 | }, 80 | "browser": { 81 | "./fs": false, 82 | "http": "stream-http", 83 | "https": "stream-http" 84 | }, 85 | "engines": { 86 | "node": ">=16.x", 87 | "npm": ">=8.x" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Agent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('node-fetch'); 3 | const FormData = require('form-data'); 4 | const qs = require('qs'); 5 | const fs = require('../fs'); 6 | const packageJson = require('../package.json'); 7 | 8 | /** 9 | * The object returned for a basic request 10 | * @typedef {object} JSONResponse 11 | * @property {number} statusCode The HTTP response status 12 | * @property {object} body The endpoint's response parsed as a JSON 13 | */ 14 | 15 | /** 16 | * The possible response from an API request 17 | * @typedef {JSONResponse | Buffer | ArrayBuffer} RequestResponse The type is based on 18 | * the request config and whether is on browser or node 19 | */ 20 | 21 | /** 22 | * The error object generated in case of a failed request 23 | * @typedef {object} RequestError 24 | * @property {number} statusCode The HTTP response status 25 | * @property {string} errorDescription Details on what caused the failed request 26 | * @property {string} shortErrorDescription Summarized version of the fail reason 27 | * @property {object} body The response object from the request 28 | * @property {object} error The error object from the request 29 | */ 30 | 31 | class Agent { 32 | constructor(baseUrl){ 33 | this.setBaseUrl(baseUrl); 34 | } 35 | 36 | setBaseUrl(baseUrl) { 37 | this.baseUrl = baseUrl; 38 | } 39 | 40 | /** 41 | * Make a GET request 42 | * @param {object} params Configurations to customize the request 43 | * @param {string} params.uri The URI to request 44 | * @param {string} [params.auth] Authorization token to use 45 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 46 | * @param {object} [params.query] Key/Value pairs of query params 47 | * @param {object} [params.context] The invocation context, describing the tool and project 48 | * @returns {Promise} A promise that resolves with either the requested data or an error object 49 | */ 50 | get({ uri, auth, headers, query, context }) { 51 | return this.request({ uri, method: 'get', auth, headers, query, context }); 52 | } 53 | 54 | /** 55 | * Make a HEAD request 56 | * @param {object} params Configurations to customize the request 57 | * @param {string} params.uri The URI to request 58 | * @param {string} [params.auth] Authorization token to use 59 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 60 | * @param {object} [params.query] Key/Value pairs of query params 61 | * @param {object} [params.context] The invocation context, describing the tool and project 62 | * @returns {Promise} A promise that resolves with either the requested data or an error object 63 | */ 64 | head({ uri, auth, headers, query, context }) { 65 | return this.request({ uri, method: 'head', auth, headers, query, context }); 66 | } 67 | 68 | /** 69 | * Make a POST request 70 | * @param {object} params Configurations to customize the request 71 | * @param {string} params.uri The URI to request 72 | * @param {string} [params.auth] Authorization token to use 73 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 74 | * @param {object} [params.data] Object to send as JSON data in the body. 75 | * @param {object} [params.context] The invocation context, describing the tool and project 76 | * @returns {Promise} A promise that resolves with either the requested data or an error object 77 | */ 78 | post({ uri, headers, data, auth, context }) { 79 | return this.request({ uri, method: 'post', auth, headers, data, context }); 80 | } 81 | 82 | /** 83 | * Make a PUT request 84 | * @param {object} params Configurations to customize the request 85 | * @param {string} params.uri The URI to request 86 | * @param {string} [params.auth] Authorization token to use 87 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 88 | * @param {object} [params.data] Object to send as JSON data in the body. 89 | * @param {object} [params.query] Key/Value pairs of query params or a correctly formatted string 90 | * @param {object} [params.context] The invocation context, describing the tool and project 91 | * @returns {Promise} A promise that resolves with either the requested data or an error object 92 | */ 93 | put({ uri, auth, headers, data, query, context }) { 94 | return this.request({ uri, method: 'put', auth, headers, data, query, context }); 95 | } 96 | 97 | /** 98 | * Make a DELETE request 99 | * @param {object} params Configurations to customize the request 100 | * @param {string} params.uri The URI to request 101 | * @param {string} [params.auth] Authorization token to use 102 | * @param {object} [params.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 103 | * @param {object} [params.data] Object to send as JSON data in the body. 104 | * @param {object} [params.context] The invocation context, describing the tool and project 105 | * @returns {Promise} A promise that resolves with either the requested data or an error object 106 | */ 107 | delete({ uri, auth, headers, data, context }) { 108 | return this.request({ uri, method: 'delete', auth, headers, data, context }); 109 | } 110 | 111 | /** 112 | * 113 | * @param {object} config An obj with all the possible request configurations 114 | * @param {string} config.uri The URI to request 115 | * @param {string} config.method The method used to request the URI, should be in uppercase. 116 | * @param {object} [config.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 117 | * @param {object} [config.data] Object to send as JSON data in the body. 118 | * @param {string} [config.auth] Authorization 119 | * @param {object} [config.query] Query parameters 120 | * @param {object} [config.form] Form fields 121 | * @param {object} [config.files] Array of file names and file content 122 | * @param {object} [config.context] The invocation context, describing the tool and project. 123 | * @param {boolean} [config.isBuffer=false] Indicate if the response should be treated as Buffer instead of JSON 124 | * @returns {Promise} A promise that resolves with either the requested data or an error object 125 | */ 126 | request({ 127 | uri, 128 | method, 129 | headers = undefined, 130 | data = undefined, 131 | auth, 132 | query = undefined, 133 | form = undefined, 134 | files = undefined, 135 | context = undefined, 136 | isBuffer = false 137 | }){ 138 | const requestFiles = this._sanitizeFiles(files); 139 | const requestParams = this._buildRequest({ uri, method, headers, data, auth, query, form, context, files: requestFiles }); 140 | return this._promiseResponse(requestParams, isBuffer); 141 | } 142 | 143 | /** 144 | * Promises to send the request and retrieve the response. 145 | * @param {[string, object]} requestParams First argument is the URI to request, the second one are the options. 146 | * @param {boolean} isBuffer Indicate if the response body should be returned as a Buffer (Node) / ArrayBuffer (browser) instead of JSON 147 | * @param {function} [makerequest=fetch] The fetch function to use. Override for testing. 148 | * @returns {Promise} A promise that resolves with either the requested data or an error object 149 | * @private 150 | */ 151 | _promiseResponse(requestParams, isBuffer, makerequest = fetch) { 152 | let status; 153 | return makerequest(...requestParams) 154 | .then((resp) => { 155 | status = resp.status; 156 | if (!resp.ok) { 157 | return resp.text().then((err) => { 158 | const objError = JSON.parse(err); 159 | // particle-commnds/src/cmd/api expects response.text. to be a string 160 | const response = Object.assign(resp, { text: err }); 161 | throw Object.assign(objError, { response }); 162 | }); 163 | } 164 | if (status === 204) { // Can't do resp.json() since there is no body to parse 165 | return ''; 166 | } 167 | if (isBuffer) { 168 | return resp.blob(); 169 | } 170 | return resp.json(); 171 | }).then((body) => { 172 | if (isBuffer) { 173 | return body.arrayBuffer().then((arrayBuffer) => { 174 | if (!this.isForBrowser()) { 175 | return Buffer.from(arrayBuffer); 176 | } 177 | return arrayBuffer; 178 | }); 179 | } 180 | return { 181 | body, 182 | statusCode: status 183 | }; 184 | }).catch((error) => { 185 | const errorType = status ? `HTTP error ${status}` : 'Network error'; 186 | let errorDescription = `${errorType} from ${requestParams[0]}`; 187 | let shortErrorDescription; 188 | if (error.error_description) { // Fetch responded with ok false 189 | errorDescription = `${errorDescription} - ${error.error_description}`; 190 | shortErrorDescription = error.error_description; 191 | } 192 | const reason = new Error(errorDescription); 193 | Object.assign(reason, { 194 | statusCode: status, 195 | errorDescription, 196 | shortErrorDescription, 197 | error, 198 | body: error 199 | }); 200 | throw reason; 201 | }); 202 | } 203 | 204 | /** 205 | * Generate the params in a format valid for 'fetch' 206 | * @param {object} config Configurations to customize the request 207 | * @param {string} config.uri The URI to request 208 | * @param {string} config.method The method used to request the URI, should be in uppercase. 209 | * @param {object} [config.headers] Key/Value pairs like `{ 'X-FOO': 'foo', X-BAR: 'bar' }` to send as headers. 210 | * @param {object} [config.data] Object to send as JSON data in the body. 211 | * @param {string} [config.auth] Authorization 212 | * @param {object} [config.query] Query parameters 213 | * @param {object} [config.form] Form fields 214 | * @param {object} [config.files] Object of key-value file names and file content 215 | * @param {object} [config.context] The invocation context, describing the tool and project. 216 | * @returns {[string, object]} The uri to make the request too, and extra configs 217 | * @private 218 | */ 219 | _buildRequest({ uri, method, headers, data, auth, query, form, files, context }){ 220 | let actualUri = uri; 221 | if (this.baseUrl && uri[0] === '/') { 222 | actualUri = `${this.baseUrl}${uri}`; 223 | } 224 | if (query) { 225 | const queryParams = qs.stringify(query); 226 | if (queryParams) { 227 | const hasParams = actualUri.includes('?'); 228 | actualUri = `${actualUri}${hasParams ? '&' : '?'}${queryParams}`; 229 | } 230 | } 231 | 232 | const userAgentHeader = { 'User-Agent': `${packageJson.name}/${packageJson.version} (${packageJson.repository.url})` }; 233 | let body; 234 | let contentTypeHeader; 235 | if (files){ 236 | // @ts-ignore 237 | contentTypeHeader = {}; // Needed to allow fetch create its own 238 | body = this._getFromData(files, form); 239 | } else if (form){ 240 | contentTypeHeader = { 'Content-Type': 'application/x-www-form-urlencoded' }; 241 | body = qs.stringify(form); 242 | } else if (data) { 243 | if (data instanceof FormData) { 244 | body = data; 245 | } else { 246 | contentTypeHeader = { 'Content-Type': 'application/json' }; 247 | body = JSON.stringify(data); 248 | } 249 | } 250 | const finalHeaders = Object.assign({}, 251 | userAgentHeader, 252 | contentTypeHeader, 253 | this._getAuthorizationHeader(auth), 254 | this._getContextHeaders(context), 255 | headers 256 | ); 257 | 258 | return [actualUri, { method, body, headers: finalHeaders }]; 259 | } 260 | 261 | isForBrowser() { 262 | return typeof window !== 'undefined'; 263 | } 264 | 265 | _getFromData(files, form) { 266 | const formData = new FormData(); 267 | for (const [name, file] of Object.entries(files)){ 268 | let path = file.path; 269 | let fileData = file.data; 270 | if (!this.isForBrowser()) { 271 | const nodeFormData = this._getNodeFormData(file); 272 | path = nodeFormData.path; 273 | fileData = nodeFormData.file; 274 | } 275 | formData.append(name, fileData, path); 276 | } 277 | if (form){ 278 | for (const [name, value] of Object.entries(form)){ 279 | formData.append(name, value); 280 | } 281 | } 282 | return formData; 283 | } 284 | 285 | _getNodeFormData(file) { 286 | let fileData = file.data; 287 | if (typeof file.data === 'string') { 288 | fileData = fs.createReadStream(file.data); 289 | } 290 | return { 291 | file: fileData, 292 | path: { filepath: file.path } // Different API for nodejs 293 | }; 294 | } 295 | 296 | _getContextHeaders(context = {}) { 297 | return Object.assign({}, 298 | this._getToolContext(context.tool), 299 | this._getProjectContext(context.project) 300 | ); 301 | } 302 | 303 | _getToolContext(tool = {}){ 304 | let value = ''; 305 | if (tool.name){ 306 | value += this._toolIdent(tool); 307 | if (tool.components){ 308 | for (const component of tool.components){ 309 | value += ', ' + this._toolIdent(component); 310 | } 311 | } 312 | } 313 | if (value){ 314 | return { 'X-Particle-Tool': value }; 315 | } 316 | return {}; 317 | } 318 | 319 | _toolIdent(tool){ 320 | return this._nameAtVersion(tool.name, tool.version); 321 | } 322 | 323 | _nameAtVersion(name, version){ 324 | let value = ''; 325 | if (name){ 326 | value += name; 327 | if (version){ 328 | value += '@' + version; 329 | } 330 | } 331 | return value; 332 | } 333 | 334 | _getProjectContext(project = {}){ 335 | const value = this._buildSemicolonSeparatedProperties(project, 'name'); 336 | if (value){ 337 | return { 'X-Particle-Project': value }; 338 | } 339 | return {}; 340 | } 341 | 342 | /** 343 | * Creates a string like primaryPropertyValue; name=value; name1=value 344 | * from the properties of an object. 345 | * @param {object} obj The object to create the string from 346 | * @param {string} primaryProperty The name of the primary property which is the default value and must be defined. 347 | * @private 348 | * @return {string} The formatted string representing the object properties and the default property. 349 | */ 350 | _buildSemicolonSeparatedProperties(obj, primaryProperty){ 351 | let value = ''; 352 | if (obj[primaryProperty]){ 353 | value += obj[primaryProperty]; 354 | for (const prop in obj){ 355 | if (prop !== primaryProperty && Object.hasOwnProperty.call(obj, prop)){ 356 | value += '; ' + prop + '=' + obj[prop]; 357 | } 358 | } 359 | } 360 | return value; 361 | } 362 | 363 | /** 364 | * Adds an authorization header. 365 | * @param {string} [auth] The authorization bearer token. 366 | * @returns {object} The original request. 367 | */ 368 | _getAuthorizationHeader(auth){ 369 | if (typeof auth === 'string') { 370 | return { Authorization: `Bearer ${auth}` }; 371 | } 372 | 373 | return {}; 374 | } 375 | 376 | /** 377 | * 378 | * @param {Object} files converts the file names to file, file1, file2. 379 | * @returns {object} the renamed files. 380 | */ 381 | _sanitizeFiles(files){ 382 | let requestFiles; 383 | if (files){ 384 | requestFiles = {}; 385 | Object.keys(files).forEach((k, i) => { 386 | const name = i ? `file${i + 1}` : 'file'; 387 | requestFiles[name] = { 388 | data: files[k], 389 | path: k 390 | }; 391 | }); 392 | } 393 | return requestFiles; 394 | } 395 | } 396 | 397 | module.exports = Agent; 398 | -------------------------------------------------------------------------------- /src/Client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Library = require('./Library'); 3 | let Particle = undefined; 4 | 5 | class Client { 6 | constructor({ auth, api = new Particle() }){ 7 | this.auth = auth; 8 | this.api = api; 9 | } 10 | 11 | ready(){ 12 | return Boolean(this.auth); 13 | } 14 | 15 | /** 16 | * Get firmware library objects 17 | * @param {Object} query The query parameters for libraries. See Particle.listLibraries 18 | * @returns {Promise} A promise 19 | */ 20 | libraries(query = {}){ 21 | return this.api.listLibraries(Object.assign({}, query, { auth: this.auth })) 22 | .then(payload => { 23 | const libraries = payload.body.data || []; 24 | return libraries.map(l => new Library(this, l)); 25 | }); 26 | } 27 | 28 | /** 29 | * Get one firmware library object 30 | * @param {String} name Name of the library to fetch 31 | * @param {Object} query The query parameters for libraries. See Particle.getLibrary 32 | * @returns {Promise} A promise 33 | */ 34 | library(name, query = {}){ 35 | return this.api.getLibrary(Object.assign({}, query, { name, auth: this.auth })) 36 | .then(payload => { 37 | const library = payload.body.data || {}; 38 | return new Library(this, library); 39 | }); 40 | } 41 | 42 | /** 43 | * Get list of library versions 44 | * @param {String} name Name of the library to fetch 45 | * @param {Object} query The query parameters for versions. See Particle.getLibraryVersions 46 | * @returns {Promise} A promise 47 | */ 48 | libraryVersions(name, query = {}){ 49 | return this.api.getLibraryVersions(Object.assign({}, query, { name, auth: this.auth })) 50 | .then(payload => { 51 | const libraries = payload.body.data || []; 52 | return libraries.map(l => new Library(this, l)); 53 | }); 54 | } 55 | 56 | /** 57 | * Contribute a new library version 58 | * @param {Buffer} archive The compressed archive with the library source 59 | * @returns {Promise} A promise 60 | */ 61 | contributeLibrary(archive){ 62 | return this.api.contributeLibrary({ archive, auth: this.auth }) 63 | .then(payload => { 64 | const library = payload.body.data || {}; 65 | return new Library(this, library); 66 | }, error => { 67 | this._throwError(error); 68 | }); 69 | } 70 | 71 | /** 72 | * Make the the most recent private library version public 73 | * @param {string} name The name of the library to publish 74 | * @return {Promise} To publish the library 75 | */ 76 | publishLibrary(name){ 77 | return this.api.publishLibrary({ name, auth: this.auth }) 78 | .then(payload => { 79 | const library = payload.body.data || {}; 80 | return new Library(this, library); 81 | }, error => { 82 | this._throwError(error); 83 | }); 84 | } 85 | 86 | /** 87 | * Delete an entire published library 88 | * @param {object} params Specific params of the library to delete 89 | * @param {string} params.name Name of the library to delete 90 | * @param {string} params.force Key to force deleting a public library 91 | * @returns {Promise} A promise 92 | */ 93 | deleteLibrary({ name, force }){ 94 | return this.api.deleteLibrary({ name, force, auth: this.auth }) 95 | .then(() => true, error => this._throwError(error)); 96 | } 97 | 98 | _throwError(error){ 99 | if (error.body && error.body.errors){ 100 | const errorMessages = error.body.errors.map((e) => e.message).join('\n'); 101 | throw new Error(errorMessages); 102 | } 103 | throw error; 104 | } 105 | 106 | downloadFile(uri){ 107 | return this.api.downloadFile({ uri }); 108 | } 109 | 110 | /** 111 | * @param {Object} files Object containing files to be compiled 112 | * @param {Number} platformId Platform id number of the device you are compiling for 113 | * @param {String} targetVersion System firmware version to compile against 114 | * @returns {Promise} A promise 115 | * @deprecated Will be removed in 6.5 116 | */ 117 | compileCode(files, platformId, targetVersion){ 118 | return this.api.compileCode({ files, platformId, targetVersion, auth: this.auth }); 119 | } 120 | 121 | /** 122 | * @param {object} params 123 | * @param {string} params.deviceId Device ID or Name 124 | * @param {boolean} params.signal Signal on or off 125 | * @returns {Promise} A promise 126 | * @deprecated Will be removed in 6.5 127 | */ 128 | signalDevice({ signal, deviceId }){ 129 | return this.api.signalDevice({ signal, deviceId, auth: this.auth }); 130 | } 131 | 132 | /** 133 | * @returns {Promise} A promise 134 | * @deprecated Will be removed in 6.5 135 | */ 136 | listDevices(){ 137 | return this.api.listDevices({ auth: this.auth }); 138 | } 139 | 140 | /** 141 | * @returns {Promise} A promise 142 | * @deprecated Will be removed in 6.5 143 | */ 144 | listBuildTargets(){ 145 | return this.api.listBuildTargets({ onlyFeatured: true, auth: this.auth }) 146 | .then(payload => { 147 | const targets = []; 148 | for (const target of payload.body.targets){ 149 | for (const platform of target.platforms){ 150 | targets.push({ 151 | version: target.version, 152 | platform: platform, 153 | prerelease: target.prereleases.indexOf(platform) > -1, 154 | firmware_vendor: target.firmware_vendor 155 | }); 156 | } 157 | } 158 | return targets; 159 | }, () => {}); 160 | } 161 | 162 | trackingIdentity({ full = false, context = undefined } = {}){ 163 | return this.api.trackingIdentity({ full, context, auth: this.auth }) 164 | .then(payload => { 165 | return payload.body; 166 | }); 167 | } 168 | } 169 | 170 | module.exports = Client; 171 | Particle = require('./Particle'); // Move it to after the export to avoid issue with circular reference 172 | -------------------------------------------------------------------------------- /src/Defaults.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | baseUrl: 'https://api.particle.io', 4 | clientSecret: 'particle-api', 5 | clientId: 'particle-api', 6 | tokenDuration: 7776000, // 90 days 7 | auth: undefined 8 | }; 9 | -------------------------------------------------------------------------------- /src/EventStream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 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 | const 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 (_err) { 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' || Object.hasOwnProperty.call(navigator, '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 | const 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 (_err) { 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 | 'use strict'; 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/Agent.integration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for real the Agent class using an external service. 3 | */ 4 | 'use strict'; 5 | const { expect } = require('./test-setup'); 6 | const Agent = require('../src/Agent'); 7 | 8 | describe('Agent', () => { 9 | if (!process.env.SKIP_AGENT_TEST){ 10 | it('can fetch a webpage', function cb() { 11 | this.retries(5); 12 | this.timeout(6000); 13 | const agent = new Agent(); 14 | const query = { a: '1', b: '2' }; 15 | const result = agent.get({ uri: 'https://postman-echo.com/get', query }); 16 | return result.then((res) => { 17 | expect(res.statusCode).to.equal(200); 18 | expect(res).has.property('body'); 19 | expect(res.body.args).to.deep.equal(query); 20 | }); 21 | }); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /test/Agent.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const FormData = require('form-data'); 3 | const { sinon, expect } = require('./test-setup'); 4 | const Agent = require('../src/Agent.js'); 5 | 6 | describe('Agent', () => { 7 | beforeEach(() => { 8 | sinon.restore(); 9 | }); 10 | 11 | describe('constructor', () => { 12 | it('calls setBaseUrl', () => { 13 | const baseUrl = 'https://foo.com'; 14 | sinon.stub(Agent.prototype, 'setBaseUrl'); 15 | const agent = new Agent(baseUrl); 16 | expect(agent.setBaseUrl).to.have.property('callCount', 1); 17 | expect(agent.setBaseUrl.firstCall.args).to.have.lengthOf(1); 18 | expect(agent.setBaseUrl.firstCall.args[0]).to.eql(baseUrl); 19 | }); 20 | }); 21 | 22 | describe('sanitize files', () => { 23 | it('can call sanitize will falsy value', () => { 24 | const agent = new Agent(); 25 | expect(agent._sanitizeFiles(undefined)).to.be.undefined; 26 | }); 27 | 28 | it('sanitizes file names', () => { 29 | const agent = new Agent(); 30 | const original = { one: 'content1', two: 'content2' }; 31 | const actual = agent._sanitizeFiles(original); 32 | expect(actual).to.eql({ 33 | 'file': { 34 | 'data': 'content1', 35 | 'path': 'one' 36 | }, 37 | 'file2': { 38 | 'data': 'content2', 39 | 'path': 'two' 40 | } 41 | }); 42 | }); 43 | }); 44 | 45 | describe('resource operations', () => { 46 | let uri, method, auth, headers, query, data, context, agent; 47 | 48 | beforeEach(() => { 49 | uri = 'http://example.com/v1'; 50 | method = 'get'; 51 | auth = 'fake-token'; 52 | headers = { 'X-FOO': 'foo', 'X-BAR': 'bar' }; 53 | query = 'foo=1&bar=2'; 54 | data = { foo: true, bar: false }; 55 | context = { blah: {} }; 56 | agent = new Agent(); 57 | agent.request = sinon.stub(); 58 | agent.request.resolves('fake-response'); 59 | }); 60 | 61 | it('can GET a resource', () => { 62 | return agent.get({ uri, auth, headers, query, context }).then(() => { 63 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, query, context }); 64 | }); 65 | }); 66 | 67 | it('can HEAD a resource', () => { 68 | method = 'head'; 69 | return agent.head({ uri, auth, headers, query, context }).then(() => { 70 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, query, context }); 71 | }); 72 | }); 73 | 74 | it('can POST a resource', () => { 75 | method = 'post'; 76 | return agent.post({ uri, auth, headers, data, context }).then(() => { 77 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context }); 78 | }); 79 | }); 80 | 81 | it('can PUT a resource', () => { 82 | method = 'put'; 83 | return agent.put({ uri, auth, headers, data, context, query }).then(() => { 84 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context, query }); 85 | }); 86 | }); 87 | 88 | it('can DELETE a resource', () => { 89 | method = 'delete'; 90 | return agent.delete({ uri, auth, headers, data, context }).then(() => { 91 | expect(agent.request).to.be.calledWith({ uri, method, auth, headers, data, context }); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('authorize', () => { 97 | let agent; 98 | 99 | beforeEach(() => { 100 | agent = new Agent(); 101 | }); 102 | 103 | it('authorize no auth is unchanged', () => { 104 | expect(agent._getAuthorizationHeader(undefined)).to.eql({}); 105 | }); 106 | 107 | it('authorize with bearer', () => { 108 | const auth = '123'; 109 | const bearer = 'Bearer 123'; 110 | const headers = agent._getAuthorizationHeader(auth); 111 | expect(headers).to.eql({ Authorization: bearer }); 112 | }); 113 | }); 114 | 115 | describe('request', () => { 116 | let agent; 117 | 118 | beforeEach(() => { 119 | agent = new Agent(); 120 | agent._promiseResponse = sinon.stub(); 121 | agent._promiseResponse.resolves('fake-response'); 122 | agent._buildRequest = sinon.stub(); 123 | agent._sanitizeFiles = sinon.stub(); 124 | }); 125 | 126 | it('sanitizes files from a request', () => { 127 | const sanitizedFiles = { a:'a' }; 128 | const files = {}; 129 | const form = {}; 130 | agent._sanitizeFiles.returns(sanitizedFiles); 131 | 132 | return agent.request({ uri: 'abc', method: 'post', data: '123', query: 'all', form, files }) 133 | .then((res) => { 134 | expect(res).to.be.equal('fake-response'); 135 | expect(agent._sanitizeFiles).calledOnce.calledWith(sinon.match.same(files)); 136 | }); 137 | }); 138 | 139 | it('uses default arguments for request', () => { 140 | const args = ['abc', { args: '123' }]; 141 | agent._buildRequest.returns(args); 142 | return agent.request({ uri: 'abc', method:'post' }) 143 | .then((res) => { 144 | expect(res).to.be.equal('fake-response'); 145 | expect(agent._promiseResponse).calledOnce.calledWith(args); 146 | }); 147 | }); 148 | 149 | it('builds and sends the request', () => { 150 | const agent = new Agent(); 151 | const options = { 152 | uri: 'http://example.com/v1', 153 | method: 'get', 154 | auth: 'fake-token', 155 | headers: { 'X-FOO': 'foo', 'X-BAR': 'bar' }, 156 | query: 'foo=1&bar=2', 157 | data: { foo: true, bar: false }, 158 | files: undefined, 159 | form: undefined, 160 | context 161 | }; 162 | agent._buildRequest = sinon.stub(); 163 | agent._buildRequest.returns('fake-request'); 164 | agent._promiseResponse = sinon.stub(); 165 | agent._promiseResponse.resolves('fake-response'); 166 | 167 | return agent.request(options).then((res) => { 168 | expect(res).to.be.equal('fake-response'); 169 | expect(agent._buildRequest).calledOnce; 170 | expect(agent._buildRequest).calledWith(options); 171 | expect(agent._promiseResponse).calledOnce; 172 | expect(agent._promiseResponse).calledWith('fake-request'); 173 | }); 174 | }); 175 | 176 | it('builds a promise to call _promiseResponse', () => { 177 | const agent = new Agent(); 178 | const req = sinon.stub(); 179 | const response = { 180 | ok: true, 181 | status: 200, 182 | json: () => Promise.resolve('response') 183 | }; 184 | req.resolves(response); 185 | const promise = agent._promiseResponse([], false, req); 186 | expect(promise).has.property('then'); 187 | return promise.then((resp) => { 188 | expect(resp).to.be.eql({ 189 | body: 'response', 190 | statusCode: 200 191 | }); 192 | }); 193 | }); 194 | 195 | it('can handle error responses', () => { 196 | const failResponseData = [ 197 | { 198 | name: 'error text includes body error description', 199 | response: { 200 | status: 404, 201 | statusText: 'file not found', 202 | text: () => Promise.resolve('{"error_description": "file not found"}') 203 | }, 204 | errorDescription: 'HTTP error 404 from 123.url - file not found' 205 | }, 206 | { 207 | name: 'error text with no body description', 208 | response: { 209 | status: 404, 210 | text: () => Promise.resolve(''), 211 | }, 212 | errorDescription: 'HTTP error 404 from 123.url' 213 | }, 214 | { 215 | name: 'error text with no status', 216 | response: {}, 217 | errorDescription: 'Network error from 123.url' 218 | } 219 | ]; 220 | const agent = new Agent(); 221 | const req = sinon.stub(); 222 | const requests = failResponseData.map((failData) => { 223 | const response = Object.assign({ 224 | ok: false 225 | }, failData.response); 226 | req.resolves(response); 227 | const promise = agent._promiseResponse(['123.url'] , false, req); 228 | return promise.catch((resp) => { 229 | expect(resp.statusCode).to.eql(failData.response.status); 230 | expect(resp.errorDescription).to.eql(failData.errorDescription); 231 | expect(resp.shortErrorDescription).to.eql(failData.response.statusText); 232 | }); 233 | }); 234 | return Promise.all(requests); 235 | }); 236 | }); 237 | 238 | describe('build request', () => { 239 | let agent; 240 | 241 | beforeEach(() => { 242 | agent = new Agent('abc'); 243 | }); 244 | 245 | it('uses a baseURL if provided', () => { 246 | const [uri] = agent._buildRequest({ uri: '/uri', method: 'get' }); 247 | expect(uri).to.equal('abc/uri'); 248 | }); 249 | 250 | it('uses the provided uri if no baseURL is provided', () => { 251 | agent.setBaseUrl(undefined); 252 | const [uri] = agent._buildRequest({ uri: 'uri', method: 'get' }); 253 | expect(uri).to.equal('uri'); 254 | }); 255 | 256 | it('generates context headers when one is provided', () => { 257 | const context = { tool: { name: 'spanner' } }; 258 | const [, opts] = agent._buildRequest({ uri: '/uri', method: 'get', context }); 259 | expect(opts.headers).to.have.property('X-Particle-Tool', 'spanner'); 260 | }); 261 | 262 | it('generates auth headers when an auth token is provided', () => { 263 | const auth = 'abcd-1235'; 264 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', auth }); 265 | expect(opts.headers).to.have.property('Authorization', `Bearer ${auth}`); 266 | }); 267 | 268 | it('adds new query params with the given query object', () => { 269 | const query = { foo: 1, bar: 2 }; 270 | const [uri] = agent._buildRequest({ uri: '/uri', method: 'get', query }); 271 | expect(uri).to.equal('abc/uri?foo=1&bar=2'); 272 | }); 273 | 274 | it('adds query params without colliding with existing ones', () => { 275 | const query = { foo: 1, bar: 2 }; 276 | const [uri] = agent._buildRequest({ uri: '/uri?test=true', method: 'get', query }); 277 | expect(uri).to.equal('abc/uri?test=true&foo=1&bar=2'); 278 | }); 279 | 280 | it('adds the provided data as a JSON request body', () => { 281 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', data: { a: 'abcd' } }); 282 | expect(opts.body).to.eql('{"a":"abcd"}'); 283 | expect(opts.headers).to.have.property('Content-Type', 'application/json'); 284 | }); 285 | 286 | it('keeps the body as provided if it is a FormData instance', () => { 287 | const data = new FormData(); 288 | data.append('file', 'data or blob'); 289 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'put', data }); 290 | expect(opts.body).to.eql(data); 291 | expect(opts.headers).to.not.have.property('Content-Type'); 292 | }); 293 | 294 | it('should setup form send when form data is given', () => { 295 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', form: { a: 'abcd' } }); 296 | expect(opts.body).to.eql('a=abcd'); 297 | }); 298 | 299 | it('should attach files', () => { 300 | const files = { 301 | file: { data: makeFile('filedata'), path: 'filepath' }, 302 | file2: { data: makeFile('file2data'), path: 'file2path' } 303 | }; 304 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 305 | expect(opts.body.toString()).to.equal('[object FormData]'); 306 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath'); 307 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('file2path'); 308 | expect(opts.headers).to.not.have.property('Content-Type'); 309 | }); 310 | 311 | it('should attach files and form data', () => { 312 | const files = { 313 | file: { data: makeFile('filedata'), path: 'filepath' }, 314 | file2: { data: makeFile('file2data'), path: 'file2path' } 315 | }; 316 | const form = { form1: 'value1', form2: 'value2' }; 317 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files, form }); 318 | expect(opts.body.toString()).to.equal('[object FormData]'); 319 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath'); 320 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('file2path'); 321 | expect(extractFormName(opts.body, 'form1', 6, true)).to.eql('value1'); 322 | expect(extractFormName(opts.body, 'form2', 9, true)).to.eql('value2'); 323 | expect(opts.headers).to.not.have.property('Content-Type'); 324 | }); 325 | 326 | it('should handle nested dirs', () => { 327 | const files = { 328 | file: { data: makeFile('filedata'), path: 'filepath.ino' }, 329 | file2: { data: makeFile('file2data'), path: 'dir/file2path.cpp' } 330 | }; 331 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 332 | expect(extractFilename(opts.body, 'file', 0)).to.eql('filepath.ino'); 333 | expect(extractFilename(opts.body, 'file2', 3)).to.eql('dir/file2path.cpp'); 334 | }); 335 | 336 | it('sets the user agent to particle-api-js', () => { 337 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get' }); 338 | expect(opts.headers).to.have.property('User-Agent').that.match(/^particle-api-js/); 339 | }); 340 | 341 | if (!inBrowser()){ 342 | it('should handle Windows nested dirs', () => { 343 | const files = { 344 | file: { data: makeFile('filedata'), path: 'dir\\windowsfilepath.cpp' } 345 | }; 346 | const [, opts] = agent._buildRequest({ uri: 'uri', method: 'get', files }); 347 | expect(extractFilename(opts.body, 'file', 0)).to.eql('dir/windowsfilepath.cpp'); 348 | }); 349 | } 350 | 351 | function inBrowser(){ 352 | return typeof window !== 'undefined'; 353 | } 354 | 355 | function makeFile(data){ 356 | if (inBrowser()){ 357 | return new Blob([data]); 358 | } else { 359 | return data; 360 | } 361 | } 362 | 363 | function extractFilename(formData, fieldName, fieldIndex){ 364 | if (inBrowser()){ 365 | return formData.get(fieldName).name; 366 | } else { 367 | return /filename="([^"]*)"/.exec(formData._streams[fieldIndex])[1]; 368 | } 369 | } 370 | 371 | function extractFormName(formData, fieldName, fieldIndex){ 372 | if (inBrowser()){ 373 | return formData.get(fieldName); 374 | } else { 375 | return formData._streams[fieldIndex + 1]; 376 | } 377 | } 378 | }); 379 | 380 | describe('context', () => { 381 | let agent; 382 | 383 | beforeEach(() => { 384 | agent = new Agent(); 385 | }); 386 | 387 | describe('_nameAtVersion', () => { 388 | it('returns empty string when no name given', () => { 389 | expect(agent._nameAtVersion('', '1.2.3')).to.eql(''); 390 | }); 391 | 392 | it('returns just the name when no version given', () => { 393 | expect(agent._nameAtVersion('fred')).to.eql('fred'); 394 | }); 395 | 396 | it('returns name@version when both are given', () => { 397 | expect(agent._nameAtVersion('fred', '1.2.3')).to.eql('fred@1.2.3'); 398 | }); 399 | }); 400 | 401 | describe('_getContextHeaders', () => { 402 | it('generates the tool context when defined', () => { 403 | const context = { tool: { name: 'spanner' } }; 404 | const subject = agent._getContextHeaders(context); 405 | expect(subject).to.have.property('X-Particle-Tool', 'spanner'); 406 | }); 407 | 408 | it('does not add the tool context header when not defined',() => { 409 | const context = { tool: { name2: 'spanner' } }; 410 | const subject = agent._getContextHeaders(context); 411 | expect(subject).to.not.have.property('X-Particle-Tool'); 412 | }); 413 | 414 | it('generates the project context header when defined',() => { 415 | const context = { project: { name: 'blinky' } }; 416 | const subject = agent._getContextHeaders(context); 417 | expect(subject).to.have.property('X-Particle-Project', 'blinky'); 418 | }); 419 | 420 | it('does not generate the project context header when not defined',() => { 421 | const context = { project: { name2: 'blinky' } }; 422 | const subject = agent._getContextHeaders(context); 423 | expect(subject).to.not.have.property('X-Particle-Project'); 424 | }); 425 | }); 426 | 427 | describe('_getToolContext', () => { 428 | it('does not add a header when the tool name is not defined', () => { 429 | const tool = { noname: 'cli' }; 430 | const subject = agent._getToolContext(tool); 431 | expect(subject).to.eql({}); 432 | }); 433 | 434 | it('adds a header when the tool is defined', () => { 435 | const tool = { name: 'cli' }; 436 | const subject = agent._getToolContext(tool); 437 | expect(subject).to.eql({ 'X-Particle-Tool': 'cli' }); 438 | }); 439 | 440 | it('adds a header when the tool and components is defined', () => { 441 | const tool = { 442 | name: 'cli', 443 | version: '1.2.3', 444 | components: [ 445 | { name: 'bar', version: 'a.b.c' }, 446 | { name: 'foo', version: '0.0.1' } 447 | ] 448 | }; 449 | const subject = agent._getToolContext(tool); 450 | expect(subject).to.eql({ 'X-Particle-Tool': 'cli@1.2.3, bar@a.b.c, foo@0.0.1' }); 451 | }); 452 | }); 453 | 454 | describe('_addProjectContext', () => { 455 | it('adds a header when the project is defined', () => { 456 | const project = { name: 'blinky' }; 457 | const subject = agent._getProjectContext(project); 458 | expect(subject).to.have.property('X-Particle-Project', 'blinky'); 459 | }); 460 | 461 | it('does not set the header when the project has no name', () => { 462 | const project = { noname: 'blinky' }; 463 | const subject = agent._getProjectContext(project); 464 | expect(subject).to.not.have.property('X-Particle-Project'); 465 | }); 466 | }); 467 | 468 | describe('_buildSemicolonSeparatedProperties', () => { 469 | const obj = { name: 'fred', color: 'pink' }; 470 | 471 | it('returns empty string when no default property', () => { 472 | expect(agent._buildSemicolonSeparatedProperties(obj)).to.be.eql(''); 473 | }); 474 | 475 | it('returns empty string when default property does not exist', () => { 476 | expect(agent._buildSemicolonSeparatedProperties(obj, 'job')).to.be.eql(''); 477 | }); 478 | 479 | it('returns the default property only', () => { 480 | expect(agent._buildSemicolonSeparatedProperties({ name:'fred' }, 'name')).eql('fred'); 481 | }); 482 | 483 | it('returns the default property plus additional properties', () => { 484 | expect(agent._buildSemicolonSeparatedProperties(obj, 'name')).eql('fred; color=pink'); 485 | }); 486 | }); 487 | }); 488 | }); 489 | -------------------------------------------------------------------------------- /test/Client.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { expect, sinon } = require('./test-setup'); 3 | const Client = require('../src/Client'); 4 | const fixtures = require('./fixtures'); 5 | const Library = require('../src/Library'); 6 | 7 | let api; 8 | const token = 'tok'; 9 | let client; 10 | 11 | 12 | describe('Client', () => { 13 | beforeEach(() => { 14 | api = {}; 15 | client = new Client({ api: api, auth: token }); 16 | }); 17 | 18 | describe('constructor', () => { 19 | it('sets the auth token', () => { 20 | expect(client.auth).to.equal(token); 21 | }); 22 | it('sets the api', () => { 23 | expect(client.api).to.equal(api); 24 | }); 25 | }); 26 | 27 | describe('libraries', () => { 28 | it('resolves to a list of Library objects', () => { 29 | api.listLibraries = () => Promise.resolve({ body: fixtures.read('libraries.json') }); 30 | return client.libraries().then(libraries => { 31 | expect(libraries.length).to.equal(1); 32 | expect(libraries[0].name).to.equal('neopixel'); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('library', () => { 38 | it('resolves to a Library objects', () => { 39 | api.getLibrary = () => Promise.resolve({ body: fixtures.read('library.json') }); 40 | return client.library('neopixel').then(library => { 41 | expect(library.name).to.equal('neopixel'); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('libraryVersions', () => { 47 | it('resolves to a Library objects', () => { 48 | api.getLibraryVersions = () => Promise.resolve({ body: fixtures.read('libraryVersions.json') }); 49 | return client.libraryVersions().then(libraries => { 50 | expect(libraries.length).to.equal(9); 51 | expect(libraries[0].name).to.equal('neopixel'); 52 | expect(libraries[0].version).to.equal('0.0.10'); 53 | expect(libraries[1].version).to.equal('0.0.9'); 54 | }); 55 | }); 56 | }); 57 | 58 | describe('downloadFile', () => { 59 | it('delegates to api', () => { 60 | api.downloadFile = ({ uri }) => Promise.resolve(`${uri} delegated`); 61 | return expect(client.downloadFile('uri')).to.eventually.equal('uri delegated'); 62 | }); 63 | }); 64 | 65 | describe('compileCode', () => { 66 | it('delegates to api', () => { 67 | api.compileCode = ({ files, platformId, targetVersion, auth }) => { 68 | return Promise.resolve([files, platformId, targetVersion, auth]); 69 | }; 70 | return expect(client.compileCode('a', 'b', 'c')).to.eventually.eql(['a', 'b', 'c', client.auth]); 71 | }); 72 | }); 73 | 74 | describe('signalDevice', () => { 75 | it('delegates to api', () => { 76 | api.signalDevice = () => { 77 | return Promise.resolve([true, client.auth]); 78 | }; 79 | return expect(client.signalDevice({ deviceId: 'testid', signal: true })) 80 | .to.eventually.eql([true, client.auth]); 81 | }); 82 | }); 83 | 84 | describe('publishLibrary', () => { 85 | it('delegates to api and returns the library metadata on success', () => { 86 | const name = 'fred'; 87 | const metadata = { name }; 88 | const library = new Library(client, metadata); 89 | api.publishLibrary = sinon.stub().resolves({ body: { data: metadata } }); 90 | return client.publishLibrary(name) 91 | .then(actual => { 92 | expect(actual).to.eql(library); 93 | expect(api.publishLibrary).to.have.been.calledWith({ name, auth:token }); 94 | }); 95 | }); 96 | 97 | it('delegates to api and calls _throwError to handle the error', () => { 98 | const error = { message:'I don\'t like vegetables' }; 99 | api.publishLibrary = sinon.stub().rejects(error); 100 | const name = 'notused'; 101 | return client.publishLibrary(name) 102 | .then(() => { 103 | throw new Error('expected an exception'); 104 | }) 105 | .catch(actual => { 106 | expect(actual).to.eql(error); 107 | expect(api.publishLibrary).to.have.been.calledWith({ name, auth:token }); 108 | }); 109 | }); 110 | }); 111 | 112 | describe('contributeLibrary', () => { 113 | it('delegates to api and returns the library metadata on success', () => { 114 | const archive = {}; 115 | const metadata = { name:'' }; 116 | const library = new Library(client, metadata); 117 | api.contributeLibrary = sinon.stub().resolves({ body: { data: metadata } }); 118 | return client.contributeLibrary(archive) 119 | .then(actual => { 120 | expect(actual).to.eql(library); 121 | expect(api.contributeLibrary).to.have.been.calledWith({ archive, auth:token }); 122 | }); 123 | }); 124 | 125 | it('delegates to api and calls _throwError to handle the error', () => { 126 | const archive = {}; 127 | const error = { message:'I don\'t like vegetables' }; 128 | api.contributeLibrary = sinon.stub().rejects(error); 129 | return client.contributeLibrary(archive) 130 | .then(() => { 131 | throw new Error('expected an exception'); 132 | }) 133 | .catch(actual => { 134 | expect(actual).to.eql(error); 135 | expect(api.contributeLibrary).to.have.been.calledWith({ archive, auth:token }); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('listDevices', () => { 141 | it('delegates to api', () => { 142 | api.listDevices = ({ auth }) => { 143 | return Promise.resolve([auth]); 144 | }; 145 | return expect(client.listDevices()).to.eventually.eql([client.auth]); 146 | }); 147 | }); 148 | 149 | describe('listBuildTargets', () => { 150 | it('delegates to api', () => { 151 | const response = { 152 | targets: [ 153 | { 154 | platforms: [0, 6], 155 | prereleases: [], 156 | version: '1.2.3', 157 | firmware_vendor: 'Foo' 158 | }, { 159 | platforms: [6, 8], 160 | prereleases: [6], 161 | version: '4.5.6', 162 | firmware_vendor: 'Bar' 163 | } 164 | ] 165 | }; 166 | const expected = [ 167 | { 168 | version: '1.2.3', 169 | platform: 0, 170 | prerelease: false, 171 | firmware_vendor: 'Foo' 172 | }, { 173 | version: '1.2.3', 174 | platform: 6, 175 | prerelease: false, 176 | 177 | firmware_vendor: 'Foo' 178 | }, { 179 | version: '4.5.6', 180 | platform: 6, 181 | prerelease: true, 182 | firmware_vendor: 'Bar' 183 | }, { 184 | version: '4.5.6', 185 | platform: 8, 186 | prerelease: false, 187 | firmware_vendor: 'Bar' 188 | }, 189 | ]; 190 | api.listBuildTargets = () => { 191 | return Promise.resolve({ body: response }); 192 | }; 193 | return expect(client.listBuildTargets()).to.eventually.eql(expected); 194 | }); 195 | }); 196 | 197 | describe('trackingIdentity', () => { 198 | it('delegates to api and unpacks the body', () => { 199 | api.trackingIdentity = ({ auth, full, context }) => { 200 | return Promise.resolve({ body: { auth, full, context } }); 201 | }; 202 | const context = { abd:123 }; 203 | const full = 456; 204 | return expect(client.trackingIdentity({ full, context })).to.eventually.eql({ auth: client.auth, full, context }); 205 | }); 206 | 207 | it('delegates to api with default parameters and unpacks the body', () => { 208 | api.trackingIdentity = ({ auth, full, context }) => { 209 | return Promise.resolve({ body: { auth, full, context } }); 210 | }; 211 | const context = undefined; 212 | const full = false; 213 | return expect(client.trackingIdentity()).to.eventually.eql({ auth:client.auth, full, context }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /test/Defaults.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { expect } = require('./test-setup'); 3 | const Defaults = require('../src/Defaults'); 4 | 5 | describe('Default Particle constructor options', () => { 6 | it('includes baseUrl', () => { 7 | expect(Defaults).to.have.property('baseUrl'); 8 | expect(Defaults.baseUrl).to.eql('https://api.particle.io'); 9 | }); 10 | 11 | it('includes clientSecret', () => { 12 | expect(Defaults).to.have.property('clientSecret'); 13 | expect(Defaults.clientSecret).to.eql('particle-api'); 14 | }); 15 | 16 | it('includes clientId', () => { 17 | expect(Defaults).to.have.property('clientId'); 18 | expect(Defaults.clientId).to.eql('particle-api'); 19 | }); 20 | 21 | it('includes tokenDuration', () => { 22 | expect(Defaults).to.have.property('tokenDuration'); 23 | expect(Defaults.tokenDuration).to.eql(7776000); 24 | }); 25 | 26 | it('includes defaultAuth', () => { 27 | expect(Defaults).to.have.property('auth'); 28 | expect(Defaults.auth).to.eql(undefined); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /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 | 'use strict'; 2 | const { sinon, expect } = require('./test-setup'); 3 | const http = require('http'); 4 | const { EventEmitter } = require('events'); 5 | 6 | const EventStream = require('../src/EventStream'); 7 | 8 | describe('EventStream', () => { 9 | afterEach(() => { 10 | sinon.restore(); 11 | }); 12 | 13 | function makeRequest() { 14 | const fakeRequest = new EventEmitter(); 15 | fakeRequest.end = sinon.spy(); 16 | fakeRequest.setTimeout = sinon.spy(); 17 | return fakeRequest; 18 | } 19 | 20 | function makeResponse(statusCode) { 21 | const fakeResponse = new EventEmitter(); 22 | fakeResponse.statusCode = statusCode; 23 | return fakeResponse; 24 | } 25 | 26 | describe('constructor', () => { 27 | it('creates an EventStream objects', () => { 28 | const eventStream = new EventStream('uri', 'token'); 29 | 30 | expect(eventStream).to.own.include({ uri: 'uri', token: 'token' }); 31 | }); 32 | }); 33 | 34 | describe('connect', () => { 35 | it('successfully connects to http', () => { 36 | sinon.useFakeTimers({ shouldAdvanceTime: true }); 37 | const fakeRequest = makeRequest(); 38 | sinon.stub(http, 'request').callsFake(() => { 39 | setImmediate(() => { 40 | const fakeResponse = makeResponse(200); 41 | fakeRequest.emit('response', fakeResponse); 42 | }); 43 | 44 | return fakeRequest; 45 | }); 46 | 47 | const eventStream = new EventStream('http://hostname:8080/path', 'token'); 48 | 49 | return eventStream.connect().then(() => { 50 | expect(http.request).to.have.been.calledWith({ 51 | hostname: 'hostname', 52 | protocol: 'http:', 53 | path: '/path?nonce=0', 54 | headers: { 55 | 'Authorization': 'Bearer token' 56 | }, 57 | method: 'get', 58 | port: 8080, 59 | mode: 'prefer-streaming' 60 | }); 61 | }); 62 | }); 63 | 64 | it('returns http errors on connect', () => { 65 | sinon.useFakeTimers({ shouldAdvanceTime: true }); 66 | const fakeRequest = makeRequest(); 67 | sinon.stub(http, 'request').callsFake(() => { 68 | setImmediate(() => { 69 | const fakeResponse = makeResponse(500); 70 | fakeRequest.emit('response', fakeResponse); 71 | setImmediate(() => { 72 | fakeResponse.emit('data', '{"error":"unknown"}'); 73 | fakeResponse.emit('end'); 74 | }); 75 | }); 76 | 77 | return fakeRequest; 78 | }); 79 | 80 | const eventStream = new EventStream('http://hostname:8080/path', 'token'); 81 | 82 | return eventStream.connect().then(() => { 83 | throw new Error('expected to throw error'); 84 | }, (reason) => { 85 | expect(reason).to.eql({ 86 | statusCode: 500, 87 | errorDescription: 'HTTP error 500 from http://hostname:8080/path', 88 | body: { 89 | error: 'unknown' 90 | } 91 | }); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('parse', () => { 97 | let eventStream; 98 | beforeEach(() => { 99 | eventStream = new EventStream(); 100 | sinon.stub(eventStream, 'parseEventStreamLine'); 101 | }); 102 | 103 | it('accumulates date into the buffer before parsing line', () => { 104 | eventStream.parse('wo'); 105 | eventStream.parse('rd'); 106 | 107 | expect(eventStream.buf).to.eql('word'); 108 | expect(eventStream.parseEventStreamLine).not.to.have.been.called; 109 | }); 110 | 111 | it('parses a line ending with \\n', () => { 112 | const line = 'field: value\n'; 113 | 114 | eventStream.parse(line); 115 | 116 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line.indexOf(':'), line.indexOf('\n')); 117 | }); 118 | 119 | it('parses 2 lines ending with \\n', () => { 120 | const line1 = 'field: value\n'; 121 | const line2 = 'field2: value2\n'; 122 | 123 | eventStream.parse(line1 + line2); 124 | 125 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line1.indexOf(':'), line1.indexOf('\n')); 126 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(line1.length, line2.indexOf(':'), line2.indexOf('\n')); 127 | }); 128 | 129 | 130 | it('parses a line ending with \\r\\n', () => { 131 | const line = 'field: value\r\n'; 132 | 133 | eventStream.parse(line); 134 | 135 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line.indexOf(':'), line.indexOf('\r')); 136 | }); 137 | 138 | it('parses 2 lines ending with \\r\\n', () => { 139 | const line1 = 'field: value\r\n'; 140 | const line2 = 'field2: value2\r\n'; 141 | 142 | eventStream.parse(line1 + line2); 143 | 144 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(0, line1.indexOf(':'), line1.indexOf('\r')); 145 | expect(eventStream.parseEventStreamLine).to.have.been.calledWith(line1.length, line2.indexOf(':'), line2.indexOf('\r')); 146 | }); 147 | 148 | it('clears buffer after parsing full lines', () => { 149 | eventStream.parse('field: value\n'); 150 | 151 | expect(eventStream.buf).to.eql(''); 152 | }); 153 | 154 | it('keeps partial lines in buffer after parsing full lines', () => { 155 | eventStream.parse('field: value\nfield2'); 156 | 157 | expect(eventStream.buf).to.eql('field2'); 158 | }); 159 | }); 160 | 161 | describe('parseEventStreamLine', () => { 162 | let eventStream; 163 | beforeEach(() => { 164 | eventStream = new EventStream(); 165 | }); 166 | 167 | it('ignores comments', () => { 168 | // comments starts with : at column 0 169 | const line = ':ok\n'; 170 | eventStream.buf = line; 171 | 172 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 173 | 174 | expect(eventStream.event).not.to.be.ok; 175 | expect(eventStream.data).to.be.eql(''); 176 | }); 177 | 178 | it('saves event name', () => { 179 | const line = 'event: testevent\n'; 180 | eventStream.buf = line; 181 | 182 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 183 | 184 | expect(eventStream.event).to.be.true; 185 | expect(eventStream.eventName).to.eql('testevent'); 186 | }); 187 | 188 | it('saves event data', () => { 189 | const line = 'data: {"data":"test"}\n'; 190 | eventStream.buf = line; 191 | 192 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 193 | 194 | expect(eventStream.data).to.eql('{"data":"test"}\n'); 195 | }); 196 | 197 | it('saves event name and data on separate lines', () => { 198 | const lines = ['event: testevent\n', 'data: {"data":"test"}\n']; 199 | for (const line of lines) { 200 | eventStream.buf = line; 201 | 202 | eventStream.parseEventStreamLine(0, line.indexOf(':'), line.indexOf('\n')); 203 | } 204 | 205 | expect(eventStream.event).to.be.true; 206 | expect(eventStream.eventName).to.eql('testevent'); 207 | expect(eventStream.data).to.eql('{"data":"test"}\n'); 208 | }); 209 | 210 | it('emits event on blank line after saving event name and data', () => { 211 | const handler = sinon.spy(); 212 | eventStream.event = true; 213 | eventStream.eventName = 'testevent'; 214 | eventStream.data = '{"data":"test"}\n'; 215 | eventStream.on('event', handler); 216 | 217 | eventStream.parseEventStreamLine(0, -1, 0); 218 | 219 | expect(handler).to.have.been.calledWith({ 220 | name: 'testevent', 221 | data: 'test' 222 | }); 223 | }); 224 | 225 | it('emits error if the event handler crashes', () => { 226 | const errorHandler = sinon.spy(); 227 | eventStream.event = true; 228 | eventStream.eventName = 'testevent'; 229 | eventStream.data = '{"data":"test"}\n'; 230 | eventStream.on('error', errorHandler); 231 | eventStream.on('event', () => { 232 | throw new Error('failed!'); 233 | }); 234 | 235 | eventStream.parseEventStreamLine(0, -1, 0); 236 | 237 | expect(errorHandler).to.have.been.called; 238 | }); 239 | 240 | it('clears the event after emitting it', () => { 241 | eventStream.event = true; 242 | eventStream.eventName = 'testevent'; 243 | eventStream.data = '{"data":"test"}\n'; 244 | 245 | eventStream.parseEventStreamLine(0, -1, 0); 246 | 247 | expect(eventStream.event).to.be.false; 248 | expect(eventStream.eventName).to.be.undefined; 249 | expect(eventStream.data).to.eql(''); 250 | }); 251 | 252 | it('ignores multiple blank lines in succession', () => { 253 | const handler = sinon.spy(); 254 | eventStream.on('event', handler); 255 | 256 | eventStream.parseEventStreamLine(0, -1, 0); 257 | eventStream.parseEventStreamLine(0, -1, 0); 258 | eventStream.parseEventStreamLine(0, -1, 0); 259 | 260 | expect(handler).not.to.have.been.called; 261 | }); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /test/FakeAgent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | class FakeAgent { 3 | get({ uri, auth, headers, query, context }){ 4 | return this.request({ uri, method: 'get', auth, headers, query, context }); 5 | } 6 | 7 | head({ uri, auth, headers, query, context }){ 8 | return this.request({ uri, method: 'head', auth, headers, query, context }); 9 | } 10 | 11 | post({ uri, headers, data, auth, context }){ 12 | return this.request({ uri, method: 'post', auth, headers, data, context }); 13 | } 14 | 15 | put({ uri, auth, headers, data, query, context }){ 16 | return this.request({ uri, method: 'put', auth, headers, data, query, context }); 17 | } 18 | 19 | delete({ uri, auth, headers, data, context }){ 20 | return this.request({ uri, method: 'delete', auth, headers, data, context }); 21 | } 22 | 23 | request(opts){ 24 | return Promise.resolve(opts); 25 | } 26 | } 27 | module.exports = FakeAgent; 28 | -------------------------------------------------------------------------------- /test/Library.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { expect } = require('./test-setup'); 3 | const Library = require('../src/Library'); 4 | 5 | const client = {}; 6 | 7 | 8 | describe('Library', () => { 9 | describe('constructor', () => { 10 | it('sets attributes', () => { 11 | const library = new Library(client, { 12 | attributes: { 13 | name: 'testlib', 14 | version: '1.0.0' 15 | } 16 | }); 17 | expect(library.name).to.equal('testlib'); 18 | expect(library.version).to.equal('1.0.0'); 19 | }); 20 | }); 21 | 22 | describe('download', () => { 23 | it('return the file contents', () => { 24 | client.downloadFile = (url) => { 25 | return Promise.resolve(`${url}-content`); 26 | }; 27 | 28 | const library = new Library(client, { 29 | attributes: { 30 | name: 'testlib', 31 | version: '1.0.0' 32 | }, 33 | links: { 34 | download: 'url' 35 | } 36 | }); 37 | expect(library.download()).to.eventually.equal('url-content'); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/Particle.integration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { expect, sinon } = require('./test-setup'); 3 | const Particle = require('../src/Particle'); 4 | 5 | describe('Particle', () => { 6 | let api; 7 | 8 | beforeEach(() => { 9 | api = new Particle({ baseUrl: '' }); 10 | }); 11 | 12 | describe('downloadFile', () => { 13 | it('download the file', () => { 14 | const uri = 'https://binaries.particle.io/libraries/neopixel/neopixel-0.0.10.tar.gz'; 15 | const fileSize = 25505; 16 | return api.downloadFile({ uri }) 17 | .then(contents => { 18 | expect(contents.length || contents.byteLength).to.equal(fileSize); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('context', () => { 24 | it('adds headers for the context', () => { 25 | api.setContext('tool', { name:'cli', version:'1.2.3' }); 26 | api.setContext('project', { name:'blinky', version:'0.0.1' }); 27 | api.agent._promiseResponse = sinon.stub().resolves(); 28 | return api.flashTinker('deviceID', 'auth').then(() => { 29 | expect(api.agent._promiseResponse).to.have.been.calledOnce; 30 | const req = api.agent._promiseResponse.firstCall.args[0]; 31 | const options = req[1]; 32 | expect(req).to.be.ok; 33 | expect(options.headers).to.have.property('X-Particle-Tool').eql('cli@1.2.3'); 34 | expect(options.headers).to.have.property('X-Particle-Project').eql('blinky; version=0.0.1'); 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 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/8e0224d0cd50f2db12368a3f07110a3c89a31bf7/test/out.tmp -------------------------------------------------------------------------------- /test/support/FixtureHttpServer.js: -------------------------------------------------------------------------------- 1 | // Serve files from the fixture folder 2 | 'use strict'; 3 | const express = require('express'); 4 | const fixtures = require('../fixtures'); 5 | 6 | 7 | class FixtureHttpServer { 8 | constructor(){ 9 | this.app = express(); 10 | this.app.get('/:filename', (req, res) => { 11 | res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); 12 | res.end(fixtures.read(req.params['filename']), 'binary'); 13 | }); 14 | } 15 | 16 | // Call in a before() test hook 17 | listen(){ 18 | return new Promise(fulfill => { 19 | this.server = this.app.listen(0, fulfill); 20 | }); 21 | } 22 | 23 | url(){ 24 | return `http://localhost:${this.server.address().port}`; 25 | } 26 | } 27 | 28 | module.exports = FixtureHttpServer; 29 | -------------------------------------------------------------------------------- /test/test-setup.js: -------------------------------------------------------------------------------- 1 | // Set up the Mocha test framework with the Chai assertion library and 2 | // the Sinon mock library 3 | 'use strict'; 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 | "noEmit": true 13 | }, 14 | "types": ["node"], 15 | "include": [ "src" ], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | 6 | module.exports = (env) => { 7 | return { 8 | mode: env.mode, 9 | target: 'web', 10 | entry: './src/Particle.js', 11 | devtool: 'source-map', 12 | output: { 13 | filename: `particle${env.mode === 'production' ? '.min' : ''}.js`, 14 | path: path.resolve(__dirname, 'dist'), 15 | clean: true, 16 | library: { 17 | name: 'Particle', 18 | type: 'var' 19 | } 20 | }, 21 | optimization: { 22 | minimize: env.mode === 'production', 23 | minimizer: [new TerserPlugin({ 24 | extractComments: false, 25 | terserOptions: { 26 | format: { 27 | comments: false 28 | } 29 | } 30 | })] 31 | }, 32 | resolve: { 33 | fallback: { 34 | buffer: require.resolve('buffer'), 35 | events: require.resolve('events'), 36 | url: require.resolve('url') 37 | } 38 | }, 39 | plugins: [ 40 | new webpack.ProvidePlugin({ 41 | Buffer: ['buffer', 'Buffer'], 42 | process: 'process/browser', 43 | }) 44 | ] 45 | }; 46 | }; 47 | --------------------------------------------------------------------------------