diff --git a/CHANGELOG.md b/CHANGELOG.md index 23251cb4..a1074c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.0.0 (2016-07-29) + +Major release addressing the following: + +### Enhancements + +- Added a simple [REST API](doc/API.md). Huge thanks to @vbanthia! + * Also, we have an example showing [how to use the API with Appium](https://github.com/openstf/stf-appium-example). + +### Breaking changes + +- The API server is a new app unit that must be added to your deployment. Please see the [deployment guide](doc/DEPLOYMENT.md) for up to date instructions. + ## 1.2.0 (2016-07-22) Minor release addressing the following: diff --git a/README.md b/README.md index 5c8ef3ff..650166ef 100644 --- a/README.md +++ b/README.md @@ -75,21 +75,23 @@ Please [contact us][contact-link] for sponsor arrangements. Both recurring and o - Rudimentary Play Store account management * List, remove and add new accounts (adding may not work on all devices) - Display hardware specs +* Simple REST [API](doc/API.md) ## Status STF is in continued, active development, but development is still largely funded by individual team members and their unpaid free time, leading to slow progress. While normal for many open source projects, STF is quite heavy on the hardware side, and is therefore somewhat of a money sink. See [how to become a sponsor](#how-to-become-a-sponsor) if you or your company would like to support future development. -We're also actively working to expand the team. Welcome **@vbanthia** as our newest full contributor! +We're also actively working to expand the team, don't be afraid to ask if you're interested. ### Short term goals Here are some things we are planning to address ASAP. -1. Properly expose the new VNC functionality in the UI -2. Implement a basic REST API for programmatically using devices +1. Performance +2. Properly expose the new VNC functionality in the UI 3. Properly reset user data between uses (Android 4.0+) 4. Automated scheduled restarts for devices +5. More! ### Consulting services diff --git a/doc/API.md b/doc/API.md new file mode 100644 index 00000000..3826cab5 --- /dev/null +++ b/doc/API.md @@ -0,0 +1,458 @@ +# STF API + +## Overview + +STF API is a RESTful API which allows you to reserve and release any STF device. Internally STF uses [Swagger](http://swagger.io/) interface for its API implementation. For those who don't know about Swagger, Swagger provides specifications for RESTful apis. By using it you can generate documentation and client SDKs in various language automatically for Swagger-enabled apps. This gives you power to use STF APIs in any language of your favorite. You can read more about Swagger [here](http://swagger.io/getting-started/). + +## Swagger documentation + +Swagger documentation for the API is available [here](https://vbanthia.github.io/angular-swagger-ui). + +## APIs + +- [Authentication](#authentication) +- [Devices](#devices) +- [User](#user) + +A few [examples](#examples) are also provided. We also have a more advanced example showing [how to use the API with Appium](https://github.com/openstf/stf-appium-example). + +### Authentication + +STF uses OAuth 2.0 for authentication. In order to use the API, you will first need to generate an access token. Access tokens can be easily generated from the STF UI. Just go to the **Settings** tab and generate a new access token in **Keys** section. Don't forget to save this token somewhere, you will not be able to see it again. + +The access token must be included in every request. + +Using cURL: + +```bash +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user +``` + +Using Node.js: + +```js + +var Swagger = require('swagger-client'); + +var SWAGGER_URL = 'https://stf.example.org/api/v1/swagger.json'; +var AUTH_TOKEN = 'xx-xxxx-xx'; + +// Without Promise +var client = new Swagger({ + url: SWAGGER_URL +, authorizations: { + accessTokenAuth: new Swagger.ApiKeyAuthorization('Authorization', 'Bearer ' + AUTH_TOKEN, 'header') + } +, success: function() { + client.user.getUser(function(user) { + console.log(user.obj) + }) + } +}); + +// Using Promise +var clientWithPromise = new Swagger({ + url: SWAGGER_URL +, usePromise: true +, authorizations: { + accessTokenAuth: new Swagger.ApiKeyAuthorization('Authorization', 'Bearer ' + AUTH_TOKEN, 'header') + } +}) + +clientWithPromise.then(function(api) { + api.user.getUser() + .then(function(res) { + console.log(res.obj.user.email) + // vishal@example.com + }) +}) +``` + +### Pretty printing output + +Please use [jq](https://stedolan.github.io/jq/manual/) for pretty printing. It's very easy to use: + +```sh +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/devices | jq . +``` + +It also provides much more complex patterns for retrieving and/or filtering data. + +### Devices + +#### GET /devices + +List **all** STF devices (including disconnected or otherwise inaccessible devices). + +```bash +GET /api/v1/devices +``` + +Using cURL: + +```bash +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/devices +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + api.devices.getDevices() + .then(function(res) { + console.log(res.obj.devices.length) + // 50 + }) +}) + +// OR +clientWithPromise.then(function(api) { + api.devices.getDevices({fields: 'serial,using,ready'}) + .then(function(res) { + console.log(res.obj.devices) + // [ { serial: 'xxxx', using: false, ready: true }, + // { serial: 'yyyy', using: false, ready: true }] + }) +}) +``` + +#### GET /devices/{serial} + +Returns information about a specific device. + +```bash +GET /api/v1/devices/{serial} +``` + +Using cURL: + +```bash +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/devices/xxxxxxxxx +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + api.devices.getDeviceBySerial({serial: 'xxxx'}) + .then(function(res) { + console.log(res.obj.device.serial) + // xxxx + }) +}) + +// OR +clientWithPromise.then(function(api) { + api.devices.getDeviceBySerial({serial: 'xxxx', fields: 'serial,using,ready'}) + .then(function(res) { + console.log(res.obj.device) + // { serial: 'xxxx', using: false, ready: true } + }) +}) +``` + +### User + +#### GET /user + +Returns information about yourself (the authenticated user). + +```bash +GET /api/v1/user +``` + +Using cURL: + +```bash +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + api.user.getUser() + .then(function(res) { + console.log(res.obj.user.email) + // vishal@example.com + }) +}) +``` + +#### GET /user/devices + +Returns a list of devices currently being used by the authenticated user. + +```bash +GET /api/v1/user/devices +``` + +Using cURL: + +```bash +curl -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user/devices +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + api.user.getUserDevices() + .then(function(res) { + console.log(res.obj.devices.length) + // 1 + }) +}) + +// OR +clientWithPromise.then(function(api) { + api.user.getUserDevices({fields: 'serial,using,ready'}) + .then(function(res) { + console.log(res.obj.devices) + // [ { serial: 'xxxx', using: true, ready: true } ] + }) +}) +``` + +#### POST /user/devices + +Attempts to add a device under the authenticated user's control. This is analogous to pressing "Use" in the UI. + +```bash +POST /api/v1/user/devices +``` + +Using cURL: + +```bash +curl -X POST --header "Content-Type: application/json" --data '{"serial":"EP7351U3WQ"}' -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user/devices +``` + +Using Node.js: + +```js +var device = {serial: 'yyyy', timeout: 900000 } + +clientWithPromise.then(function(api) { + return api.user.addUserDevice({device: device}) + .then(function(res) { + console.log(res.obj) + // { success: true, description: 'Device successfully added' } + }) +}) +.catch(function(err) { + console.log(err) +}) +``` + +#### DELETE /user/devices/{serial} + +Removes a device from the authenticated user's device list. This is analogous to pressing "Stop using" in the UI. + +```bash +DELETE /api/v1/user/devices/{serial} +``` + +Using cURL: + +```bash +curl -X DELETE -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user/devices/{serial} +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + return api.user.deleteUserDeviceBySerial({serial: 'yyyy'}) + .then(function(res) { + console.log(res.obj) + // { success: true, description: 'Device successfully removed' } + }) +}) +.catch(function(err) { + console.log(err) +}) +``` + +#### POST /user/devices/{serial}/remoteConnect + +Allows you to retrieve the remote debug URL (i.e. an `adb connect`able address) for a device the authenticated user controls. + +_Note that if you haven't added your ADB key to STF yet, the device may be in unauthorized state after connecting to it for the first time. We recommend you make sure your ADB key has already been set up properly before you start using this API. You can add your ADB key from the settings page, or by connecting to a device you're actively using in the UI and responding to the dialog that appears._ + +```bash +POST /api/v1/user/devices/{serial}/remoteConnect +``` + +Using cURL: + +```bash +curl -X POST --header "Content-Type: application/json" -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user/devices/{serial}/remoteConnect +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + return api.user.remoteConnectUserDeviceBySerial({serial: 'CB5125LBYM'}) + .then(function(res) { + console.log(res.obj.remoteConnectUrl) + // $PROVIDER_IP:16829 + }) +}) +.catch(function(err) { + console.log(err) + // {"success":false, + // "description":"Device is not owned by you or is not available"}' } +}) +``` + +#### DELETE /api/v1/user/devices/{serial}/remoteConnect + +Disconnect a remote debugging session. + +```bash +DELETE /api/v1/user/devices/{serial}/remoteConnect +``` + +Using cURL: + +```bash +curl -X DELETE -H "Authorization: Bearer YOUR-TOKEN-HERE" https://stf.example.org/api/v1/user/devices/{serial}/remoteConnect +``` + +Using Node.js: + +```js +clientWithPromise.then(function(api) { + return api.user.remoteDisconnectUserDeviceBySerial({serial: 'CB5125LBYM'}) + .then(function(res) { + console.log(res.obj) + // { success: true, + // description: 'Device remote disconnected successfully' } + }) +}) +.catch(function(err) { + console.log(err) + // {"success":false,"description":"Device is not owned by you or is not available"} +}) +``` + +## Examples + +### Connect to a device and retrieve its remote debug URL + +```js +// stf-connect.js + +var Swagger = require('swagger-client'); + +var SWAGGER_URL = 'https://stf.example.org/api/v1/swagger.json'; +var AUTH_TOKEN = 'xx-xxxx-xx'; + +// Using Promise +var client = new Swagger({ + url: SWAGGER_URL +, usePromise: true +, authorizations: { + accessTokenAuth: new Swagger.ApiKeyAuthorization('Authorization', 'Bearer ' + AUTH_TOKEN, 'header') + } +}) + +var serial = process.argv.slice(2)[0] + +client.then(function(api) { + return api.devices.getDeviceBySerial({ + serial: serial + , fields: 'serial,present,ready,using,owner' + }).then(function(res) { + // check if device can be added or not + var device = res.obj.device + if (!device.present || !device.ready || device.using || device.owner) { + console.log('Device is not available') + return + } + + // add device to user + return api.user.addUserDevice({ + device: { + serial: device.serial + , timeout: 900000 + } + }).then(function(res) { + if (!res.obj.success) { + console.log('Could not add device') + return + } + + // get remote connect url + return api.user.remoteConnectUserDeviceBySerial({ + serial: device.serial + }).then(function(res) { + console.log(res.obj.remoteConnectUrl) + }) + }) + }) +}) +``` + +```bash +node stf-connect.js xxxx +# $PROVIDR_IP:16829 +``` + +### Disconnect a device once you no longer need it + +```js +var Swagger = require('swagger-client'); + +var SWAGGER_URL = 'https://stf.example.org/api/v1/swagger.json'; +var AUTH_TOKEN = 'xx-xxxx-xx'; + +var client = new Swagger({ + url: SWAGGER_URL +, usePromise: true +, authorizations: { + accessTokenAuth: new Swagger.ApiKeyAuthorization('Authorization', 'Bearer ' + AUTH_TOKEN, 'header') + } +}) + +var serial = process.argv.slice(2)[0] + +client.then(function(api) { + return api.user.getUserDevices({ + serial: serial + , fields: 'serial,present,ready,using,owner' + }).then(function(res) { + // check if user has that device or not + var devices = res.obj.devices + var hasDevice = false + + devices.forEach(function(device) { + if(device.serial === serial) { + hasDevice = true; + } + }) + + if (!hasDevice) { + console.log('You do not own that device') + return + } + + return api.user.deleteUserDeviceBySerial({ + serial: serial + }).then(function(res) { + if (!res.obj.success) { + console.log('Could not disconnect') + return + } + console.log('Device disconnected successfully!') + }) + }) +}) +``` + +```bash +node stf-disconnect.js xxxx +# Device disconnected successfully! +``` diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index c306daee..66b17e88 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -55,6 +55,7 @@ The app role can contain any of the following units. You may distribute them as * [stf-triproxy-app.service](#stf-triproxy-appservice) * [stf-triproxy-dev.service](#stf-triproxy-devservice) * [stf-websocket@.service](#stf-websocketservice) +* [stf-api@.service](#stf-apiservice) ### Database role @@ -127,7 +128,7 @@ Requires=docker.service EnvironmentFile=/etc/environment TimeoutStartSec=0 Restart=always -ExecStartPre=/usr/bin/docker pull rethinkdb:2.1.1 +ExecStartPre=/usr/bin/docker pull rethinkdb:2.3 ExecStartPre=-/usr/bin/docker kill %p ExecStartPre=-/usr/bin/docker rm %p ExecStartPre=/usr/bin/mkdir -p /srv/rethinkdb @@ -137,7 +138,7 @@ ExecStart=/usr/bin/docker run --rm \ -v /srv/rethinkdb:/data \ -e "AUTHKEY=YOUR_RETHINKDB_AUTH_KEY_HERE_IF_ANY" \ --net host \ - rethinkdb:2.1.1 \ + rethinkdb:2.1.5 \ rethinkdb --bind all \ --cache-size 8192 ExecStop=-/usr/bin/docker stop -t 10 %p @@ -179,7 +180,7 @@ These units are required for proper operation of STF. Unless mentioned otherwise **Requires** the `rethinkdb-proxy-28015.service` unit on the same host. -The app unit provides the main HTTP server and currently a very, very modest API for the client-side. It also serves all static resources including images, scripts and stylesheets. +The app unit provides the main HTTP server and it serves all static resources including images, scripts and stylesheets. This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-app@3100.service` runs on port 3100). You can have multiple instances running on the same host by using different ports. @@ -653,6 +654,39 @@ ExecStart=/usr/bin/docker run --rm \ ExecStop=/usr/bin/docker stop -t 10 %p-%i ``` +### `stf-api@.service` + +**Requires** the `rethinkdb-proxy-28015.service` unit on the same host. + +The api unit provides all the major RESTful APIs for STF. Users can generate their personal access token from STF UI and can use that token to access these api from any interface. + +This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-api@3700.service` runs on port 3700). You can have multiple instances running on the same host by using different ports. + +```ini +[Unit] +Description=STF api +After=rethinkdb-proxy-28015.service +BindsTo=rethinkdb-proxy-28015.service + +[Service] +EnvironmentFile=/etc/environment +TimeoutStartSec=0 +Restart=always +ExecStartPre=/usr/bin/docker pull openstf/stf:latest +ExecStartPre=-/usr/bin/docker kill %p-%i +ExecStartPre=-/usr/bin/docker rm %p-%i +ExecStart=/usr/bin/docker run --rm \ + --name %p-%i \ + --link rethinkdb-proxy-28015:rethinkdb \ + -e "SECRET=YOUR_SESSION_SECRET_HERE" \ + -p %i:3000 \ + openstf/stf:latest \ + stf api --port 3000 \ + --connect-sub tcp://appside.stf.example.org:7150 \ + --connect-push tcp://appside.stf.example.org:7170 +ExecStop=-/usr/bin/docker stop -t 10 %p-%i +``` + ## Optional units These units are optional and don't affect the way STF works in any way. @@ -836,6 +870,10 @@ http { server 192.168.255.100:3600 max_fails=0; } + upstream stf_api { + server 192.168.255.100:3700 max_fails=0; + } + types { application/javascript js; image/gif gif; @@ -904,6 +942,10 @@ http { proxy_pass http://stf_auth/auth/; } + location /api/ { + proxy_pass http://stf_api/api/; + } + location /s/image/ { proxy_pass http://stf_storage_image; } diff --git a/lib/cli.js b/lib/cli.js index d6680620..0af3aff2 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -758,6 +758,9 @@ program .option('-u, --app-url ' , 'URL to app' , String) + .option('-i, --api-url ' + , 'URL to api' + , String) .option('-a, --auth-url ' , 'URL to auth client' , String) @@ -780,6 +783,9 @@ program if (!options.authUrl) { this.missingArgument('--auth-url') } + if (!options.apiUrl) { + this.missingArgument('--api-url') + } if (!options.websocketUrl) { this.missingArgument('--websocket-url') } @@ -796,6 +802,7 @@ program require('./units/poorxy')({ port: options.port , appUrl: options.appUrl + , apiUrl: options.apiUrl , authUrl: options.authUrl , websocketUrl: options.websocketUrl , storageUrl: options.storageUrl @@ -849,6 +856,49 @@ program }) }) + program + .command('api') + .description('start api') + .option('-p, --port ' + , 'port (or $PORT)' + , Number + , process.env.PORT || 7106) + .option('-i, --ssid ' + , 'session SSID (or $SSID)' + , String + , process.env.SSID || 'ssid') + .option('-s, --secret ' + , 'secret (or $SECRET)' + , String + , process.env.SECRET) + .option('-c, --connect-push ' + , 'push endpoint' + , cliutil.list) + .option('-u, --connect-sub ' + , 'sub endpoint' + , cliutil.list) + .action(function(options) { + if (!options.secret) { + this.missingArgument('--secret') + } + if (!options.connectPush) { + this.missingArgument('--connect-push') + } + if (!options.connectSub) { + this.missingArgument('--connect-sub') + } + + require('./units/api')({ + port: options.port + , ssid: options.ssid + , secret: options.secret + , endpoints: { + push: options.connectPush + , sub: options.connectSub + } + }) + }) + program .command('websocket') .description('start websocket') @@ -1110,6 +1160,10 @@ program , 'app port' , Number , 7105) + .option('--api-port ' + , 'api port' + , Number + , 7106) .option('--websocket-port ' , 'websocket port' , Number @@ -1285,6 +1339,14 @@ program return extra })())) + // api + , procutil.fork(__filename, [ + 'api' + , '--port', options.apiPort + , '--secret', options.authSecret + , '--connect-push', options.bindAppPull + , '--connect-sub', options.bindAppPub + ]) // websocket , procutil.fork(__filename, [ 'websocket' @@ -1326,6 +1388,8 @@ program , util.format('http://localhost:%d/', options.appPort) , '--auth-url' , util.format('http://localhost:%d/', options.authPort) + , '--api-url' + , util.format('http://localhost:%d/', options.apiPort) , '--websocket-url' , util.format('http://localhost:%d/', options.websocketPort) , '--storage-url' diff --git a/lib/db/api.js b/lib/db/api.js index 59c801f0..c5d7adfc 100644 --- a/lib/db/api.js +++ b/lib/db/api.js @@ -121,7 +121,7 @@ dbapi.lookupUserByVncAuthResponse = function(response, serial) { }) } -dbapi.loadGroup = function(email) { +dbapi.loadUserDevices = function(email) { return db.run(r.table('devices').getAll(email, { index: 'owner' })) @@ -151,6 +151,8 @@ dbapi.saveDeviceInitialState = function(serial, device) { , statusChangedAt: r.now() , ready: false , reverseForwards: [] + , remoteConnect: false + , remoteConnectUrl: null } return db.run(r.table('devices').get(serial).update(data)) .then(function(stats) { @@ -163,6 +165,20 @@ dbapi.saveDeviceInitialState = function(serial, device) { }) } +dbapi.setDeviceConnectUrl = function(serial, url) { + return db.run(r.table('devices').get(serial).update({ + remoteConnectUrl: url + , remoteConnect: true + })) +} + +dbapi.unsetDeviceConnectUrl = function(serial, url) { + return db.run(r.table('devices').get(serial).update({ + remoteConnectUrl: null + , remoteConnect: false + })) +} + dbapi.saveDeviceStatus = function(serial, status) { return db.run(r.table('devices').get(serial).update({ status: status @@ -329,4 +345,8 @@ dbapi.loadAccessTokens = function(email) { })) } +dbapi.loadAccessToken = function(id) { + return db.run(r.table('accessTokens').get(id)) +} + module.exports = dbapi diff --git a/lib/units/api/config/default.yaml b/lib/units/api/config/default.yaml new file mode 100644 index 00000000..360e0a84 --- /dev/null +++ b/lib/units/api/config/default.yaml @@ -0,0 +1,41 @@ +# swagger configuration file + +# values in the swagger hash are system configuration for swagger-node +swagger: + + fittingsDirs: [ fittings ] + defaultPipe: null + swaggerControllerPipe: swagger_controllers # defines the standard processing pipe for controllers + + # values defined in the bagpipes key are the bagpipes pipes and fittings definitions + # (see https://github.com/apigee-127/bagpipes) + bagpipes: + + _router: + name: swagger_router + mockMode: false + mockControllersDirs: [ mocks ] + controllersDirs: [ controllers ] + + _swagger_validate: + name: swagger_validator + validateResponse: true + + _swagger_security: + name: swagger_security + securityHandlersModule: helpers/securityHandlers + + # pipe for all swagger-node controllers + swagger_controllers: + - onError: json_error_handler + - swagger_params_parser + - swagger_security + - _swagger_validate + - express_compatibility + - _router + + # pipe to serve swagger (endpoint is in swagger.yaml) + swagger_raw: + name: swagger_raw + +# any other values in this file are just loaded into the config for application access... diff --git a/lib/units/api/controllers/devices.js b/lib/units/api/controllers/devices.js new file mode 100644 index 00000000..ba236b4f --- /dev/null +++ b/lib/units/api/controllers/devices.js @@ -0,0 +1,79 @@ +var _ = require('lodash') +var Promise = require('bluebird') + +var dbapi = require('../../../db/api') +var logger = require('../../../util/logger') +var datautil = require('../../../util/datautil') + +var log = logger.createLogger('api:controllers:devices') + +module.exports = { + getDevices: getDevices +, getDeviceBySerial: getDeviceBySerial +} + +function getDevices(req, res) { + var fields = req.swagger.params.fields.value + + dbapi.loadDevices() + .then(function(cursor) { + return Promise.promisify(cursor.toArray, cursor)() + .then(function(list) { + var deviceList = [] + + list.forEach(function(device) { + datautil.normalize(device, req.user) + var responseDevice = device + + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + deviceList.push(responseDevice) + }) + + res.json({ + success: true + , devices: deviceList + }) + }) + }) + .catch(function(err) { + log.error('Failed to load device list: ', err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function getDeviceBySerial(req, res) { + var serial = req.swagger.params.serial.value + var fields = req.swagger.params.fields.value + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + var responseDevice = device + + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + + res.json({ + success: true + , device: responseDevice + }) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} diff --git a/lib/units/api/controllers/user.js b/lib/units/api/controllers/user.js new file mode 100644 index 00000000..6cfb5fa2 --- /dev/null +++ b/lib/units/api/controllers/user.js @@ -0,0 +1,400 @@ +var util = require('util') + +var _ = require('lodash') +var Promise = require('bluebird') +var uuid = require('node-uuid') + +var dbapi = require('../../../db/api') +var logger = require('../../../util/logger') +var datautil = require('../../../util/datautil') +var deviceutil = require('../../../util/deviceutil') +var wire = require('../../../wire') +var wireutil = require('../../../wire/util') +var wirerouter = require('../../../wire/router') + +var log = logger.createLogger('api:controllers:user') + +module.exports = { + getUser: getUser +, getUserDevices: getUserDevices +, addUserDevice: addUserDevice +, getUserDeviceBySerial: getUserDeviceBySerial +, deleteUserDeviceBySerial: deleteUserDeviceBySerial +, remoteConnectUserDeviceBySerial: remoteConnectUserDeviceBySerial +, remoteDisconnectUserDeviceBySerial: remoteDisconnectUserDeviceBySerial +, getUserAccessTokens: getUserAccessTokens +} + +function getUser(req, res) { + res.json({ + success: true + , user: req.user + }) +} + +function getUserDevices(req, res) { + var fields = req.swagger.params.fields.value + + dbapi.loadUserDevices(req.user.email) + .then(function(cursor) { + return Promise.promisify(cursor.toArray, cursor)() + .then(function(list) { + var deviceList = [] + + list.forEach(function(device) { + datautil.normalize(device, req.user) + var responseDevice = device + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + deviceList.push(responseDevice) + }) + + res.json({ + success: true + , devices: deviceList + }) + }) + }) + .catch(function(err) { + log.error('Failed to load device list: ', err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function getUserDeviceBySerial(req, res) { + var serial = req.swagger.params.serial.value + var fields = req.swagger.params.fields.value + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you' + }) + } + + var responseDevice = device + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + + res.json({ + success: true + , device: responseDevice + }) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function addUserDevice(req, res) { + var serial = req.body.serial + var timeout = req.body.timeout || null + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isAddable(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is being used or not available' + }) + } + + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var responseTimer = setTimeout(function() { + req.options.channelRouter.removeListener(wireutil.global, messageListener) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) + + var messageListener = wirerouter() + .on(wire.JoinGroupMessage, function(channel, message) { + if (message.serial === serial && message.owner.email === req.user.email) { + clearTimeout(responseTimer) + req.options.channelRouter.removeListener(wireutil.global, messageListener) + + return res.json({ + success: true + , description: 'Device successfully added' + }) + } + }) + .handler() + + req.options.channelRouter.on(wireutil.global, messageListener) + + req.options.push.send([ + device.channel + , wireutil.envelope( + new wire.GroupMessage( + new wire.OwnerMessage( + req.user.email + , req.user.name + , req.user.group + ) + , timeout + , wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + ) + ) + ]) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function deleteUserDeviceBySerial(req, res) { + var serial = req.swagger.params.serial.value + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'You cannot release this device. Not owned by you' + }) + } + + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var responseTimer = setTimeout(function() { + req.options.channelRouter.removeListener(wireutil.global, messageListener) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) + + var messageListener = wirerouter() + .on(wire.LeaveGroupMessage, function(channel, message) { + if (message.serial === serial && message.owner.email === req.user.email) { + clearTimeout(responseTimer) + req.options.channelRouter.removeListener(wireutil.global, messageListener) + + return res.json({ + success: true + , description: 'Device successfully removed' + }) + } + }) + .handler() + + req.options.channelRouter.on(wireutil.global, messageListener) + + req.options.push.send([ + device.channel + , wireutil.envelope( + new wire.UngroupMessage( + wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + ) + ) + ]) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function remoteConnectUserDeviceBySerial(req, res) { + var serial = req.swagger.params.serial.value + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you or is not available' + }) + } + + var responseChannel = 'txn_' + uuid.v4() + req.options.sub.subscribe(responseChannel) + + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var timer = setTimeout(function() { + req.options.channelRouter.removeListener(responseChannel, messageListener) + req.options.sub.unsubscribe(responseChannel) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) + + var messageListener = wirerouter() + .on(wire.ConnectStartedMessage, function(channel, message) { + if (message.serial === serial) { + clearTimeout(timer) + req.options.sub.unsubscribe(responseChannel) + req.options.channelRouter.removeListener(responseChannel, messageListener) + + return res.json({ + success: true + , remoteConnectUrl: message.url + }) + } + }) + .handler() + + req.options.channelRouter.on(responseChannel, messageListener) + + req.options.push.send([ + device.channel + , wireutil.transaction( + responseChannel + , new wire.ConnectStartMessage() + ) + ]) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function remoteDisconnectUserDeviceBySerial(req, res) { + var serial = req.swagger.params.serial.value + + dbapi.loadDevice(serial) + .then(function(device) { + if (!device) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you or is not available' + }) + } + + var responseChannel = 'txn_' + uuid.v4() + req.options.sub.subscribe(responseChannel) + + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var timer = setTimeout(function() { + req.options.channelRouter.removeListener(responseChannel, messageListener) + req.options.sub.unsubscribe(responseChannel) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) + + var messageListener = wirerouter() + .on(wire.ConnectStoppedMessage, function(channel, message) { + if (message.serial === serial) { + clearTimeout(timer) + req.options.sub.unsubscribe(responseChannel) + req.options.channelRouter.removeListener(responseChannel, messageListener) + + return res.json({ + success: true + , description: 'Device remote disconnected successfully' + }) + } + }) + .handler() + + req.options.channelRouter.on(responseChannel, messageListener) + + req.options.push.send([ + device.channel + , wireutil.transaction( + responseChannel + , new wire.ConnectStopMessage() + ) + ]) + }) + .catch(function(err) { + log.error('Failed to load device "%s": ', req.params.serial, err.stack) + res.status(500).json({ + success: false + }) + }) +} + +function getUserAccessTokens(req, res) { + dbapi.loadAccessTokens(req.user.email) + .then(function(cursor) { + return Promise.promisify(cursor.toArray, cursor)() + .then(function(list) { + var titles = [] + list.forEach(function(token) { + titles.push(token.title) + }) + res.json({ + success: true + , titles: titles + }) + }) + }) + .catch(function(err) { + log.error('Failed to load tokens: ', err.stack) + res.status(500).json({ + success: false + }) + }) +} diff --git a/lib/units/api/helpers/securityHandlers.js b/lib/units/api/helpers/securityHandlers.js new file mode 100644 index 00000000..99eedd1c --- /dev/null +++ b/lib/units/api/helpers/securityHandlers.js @@ -0,0 +1,100 @@ +var dbapi = require('../../../db/api') +var jwtutil = require('../../../util/jwtutil') +var urlutil = require('../../../util/urlutil') +var logger = require('../../../util/logger') + +var log = logger.createLogger('api:helpers:securityHandlers') + +module.exports = { + accessTokenAuth: accessTokenAuth +} + +// Specifications: https://tools.ietf.org/html/rfc6750#section-2.1 + +function accessTokenAuth(req, res, next) { + if (req.headers.authorization) { + var authHeader = req.headers.authorization.split(' ') + var format = authHeader[0] + var tokenId = authHeader[1] + + if (format !== 'Bearer') { + return res.status(401).json({ + success: false + , description: 'Authorization header should be in "Bearer $AUTH_TOKEN" format' + }) + } + + if (!tokenId) { + log.error('Bad Access Token Header') + return res.status(401).json({ + success: false + , description: 'Bad Credentials' + }) + } + + dbapi.loadAccessToken(tokenId) + .then(function(token) { + if (!token) { + return res.status(401).json({ + success: false + , description: 'Bad Credentials' + }) + } + + var jwt = token.jwt + var data = jwtutil.decode(jwt, req.options.secret) + + if (!data) { + return res.status(500).json({ + success: false + }) + } + dbapi.loadUser(data.email) + .then(function(user) { + if (user) { + req.user = user + next() + } + else { + return res.status(500).json({ + success: false + }) + } + }) + .catch(function(err) { + log.error('Failed to load user: ', err.stack) + }) + }) + .catch(function(err) { + log.error('Failed to load token: ', err.stack) + return res.status(401).json({ + success: false + , description: 'Bad Credentials' + }) + }) + } + // Request is coming from browser app + // TODO: Remove this once frontend become stateless + // and start sending request without session + else if (req.session && req.session.jwt) { + dbapi.loadUser(req.session.jwt.email) + .then(function(user) { + if (user) { + req.user = user + next() + } + else { + return res.status(500).json({ + success: false + }) + } + }) + .catch(next) + } + else { + res.status(401).json({ + success: false + , description: 'Requires Authentication' + }) + } +} diff --git a/lib/units/api/index.js b/lib/units/api/index.js new file mode 100644 index 00000000..f89e4620 --- /dev/null +++ b/lib/units/api/index.js @@ -0,0 +1,109 @@ +var http = require('http') +var path = require('path') +var events = require('events') + +var express = require('express') +var SwaggerExpress = require('swagger-express-mw') +var cookieSession = require('cookie-session') +var Promise = require('bluebird') +var _ = require('lodash') + +var logger = require('../../util/logger') +var zmqutil = require('../../util/zmqutil') +var srv = require('../../util/srv') +var lifecycle = require('../../util/lifecycle') +var wireutil = require('../../wire/util') + +module.exports = function(options) { + var log = logger.createLogger('api') + var app = express() + var server = http.createServer(app) + var channelRouter = new events.EventEmitter() + + var push = zmqutil.socket('push') + Promise.map(options.endpoints.push, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Sending output to "%s"', record.url) + push.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to push endpoint', err) + lifecycle.fatal() + }) + + // Input + var sub = zmqutil.socket('sub') + Promise.map(options.endpoints.sub, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Receiving input from "%s"', record.url) + sub.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to sub endpoint', err) + lifecycle.fatal() + }) + + // Establish always-on channels + ;[wireutil.global].forEach(function(channel) { + log.info('Subscribing to permanent channel "%s"', channel) + sub.subscribe(channel) + }) + + sub.on('message', function(channel, data) { + channelRouter.emit(channel.toString(), channel, data) + }) + + // Swagger Express Config + var config = { + appRoot: __dirname + , swaggerFile: path.resolve(__dirname, 'swagger', 'api_v1.yaml') + } + + SwaggerExpress.create(config, function(err, swaggerExpress) { + if (err) { + throw err + } + swaggerExpress.register(app) + }) + + // Adding options in request, so that swagger controller + // can use it. + app.use(function(req, res, next) { + var reqOptions = _.merge(options, { + push: push + , sub: sub + , channelRouter: channelRouter + }) + + req.options = reqOptions + next() + }) + + // TODO: Remove this once frontend is stateless + app.use(cookieSession({ + name: options.ssid + , keys: [options.secret] + })) + + lifecycle.observe(function() { + [push, sub].forEach(function(sock) { + try { + sock.close() + } + catch (err) { + // No-op + } + }) + }) + + server.listen(options.port) + log.info('Listening on port %d', options.port) +} diff --git a/lib/units/api/swagger/api_v1.yaml b/lib/units/api/swagger/api_v1.yaml new file mode 100644 index 00000000..10a9c97f --- /dev/null +++ b/lib/units/api/swagger/api_v1.yaml @@ -0,0 +1,327 @@ +swagger: "2.0" +info: + version: "2.0.0" + title: Smartphone Test Farm + description: Control and manages real Smartphone devices from browser and restful apis + license: + name: Apache-2.0 + url: http://www.apache.org/licenses/LICENSE-2.0 + contact: + name: STF Team + email: contact@openstf.io + url: http://openstf.io/ +basePath: /api/v1 +schemes: + - http + - https +consumes: + - application/json +produces: + - application/json +tags: + - name: user + description: User Operations + - name: devices + description: Device Operations +paths: + /user: + x-swagger-router-controller: user + get: + summary: User Profile + description: The User Profile endpoint returns information about current authorized user + operationId: getUser + tags: + - user + responses: + "200": + description: Current User Profile information + schema: + $ref: "#/definitions/UserResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /user/devices: + x-swagger-router-controller: user + get: + summary: User Devices + description: The User Devices endpoint returns device list owner by current authorized user + operationId: getUserDevices + tags: + - user + parameters: + - name: fields + in: query + description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response + required: false + type: string + responses: + "200": + description: Current User Devices List + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + post: + summary: Add a device to a user + description: The User Devices endpoint will request stf server for a new device. + operationId: addUserDevice + tags: + - user + parameters: + - name: device + in: body + description: Device to add + required: true + schema: + $ref: "#/definitions/AddUserDevicePayload" + responses: + "200": + description: Add User Device Status + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /user/devices/{serial}: + x-swagger-router-controller: user + get: + summary: User Device + description: The devices enpoint return information about device owned by user + operationId: getUserDeviceBySerial + tags: + - user + parameters: + - name: serial + in: path + description: Device Serial + required: true + type: string + - name: fields + in: query + description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response + required: false + type: string + responses: + "200": + description: Device Information owned by user + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Delete User Device + description: The User Devices endpoint will request for device release from stf server. It will return request accepted if device is being used by current user + operationId: deleteUserDeviceBySerial + tags: + - user + parameters: + - name: serial + in: path + description: Device Serial + required: true + type: string + responses: + "200": + description: Delete User Device Status + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + # I do know this is against REST principal to use verb as endpoint. But I feel it is more easy to + # understand in comparision of using PUT/PATCH + /user/devices/{serial}/remoteConnect: + x-swagger-router-controller: user + post: + summary: Remote Connect + description: The device connect endpoint will request stf server to connect remotely + operationId: remoteConnectUserDeviceBySerial + tags: + - user + parameters: + - name: serial + in: path + description: Device Serial + required: true + type: string + responses: + "200": + description: Remote Connect User Device Request Status + schema: + $ref: "#/definitions/RemoteConnectUserDeviceResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Remote Disconnect + description: The device connect endpoint will request stf server to disconnect remotely + operationId: remoteDisconnectUserDeviceBySerial + tags: + - user + parameters: + - name: serial + in: path + description: Device Serial + required: true + type: string + responses: + "200": + description: Remote Disonnect User Device Request Status + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /user/accessTokens: + x-swagger-router-controller: user + get: + summary: Access Tokens + description: The Access Tokens endpoints returns titles of all the valid access tokens + operationId: getUserAccessTokens + tags: + - user + responses: + "200": + description: Access Tokens titles + schema: + $ref: "#/definitions/AccessTokensResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /devices: + x-swagger-router-controller: devices + get: + summary: Device List + description: The devices endpoint return list of all the STF devices including Disconnected and Offline + operationId: getDevices + tags: + - devices + parameters: + - name: fields + in: query + description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response + required: false + type: string + responses: + "200": + description: List of Devices + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /devices/{serial}: + x-swagger-router-controller: devices + get: + summary: Device Information + description: The device enpoint return information about a single device + operationId: getDeviceBySerial + tags: + - devices + parameters: + - name: serial + in: path + description: Device Serial + required: true + type: string + - name: fields + in: query + description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response + required: false + type: string + responses: + "200": + description: Device Information + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: Unexpected Error + schema: + $ref: "#/definitions/ErrorResponse" + security: + - accessTokenAuth: [] + /swagger.json: + x-swagger-pipe: swagger_raw +definitions: + UserResponse: + required: + - user + properties: + user: + type: object + AccessTokensResponse: + required: + - tokens + properties: + tokens: + type: array + items: + type: string + DeviceListResponse: + required: + - devices + properties: + devices: + type: array + items: + type: object + DeviceResponse: + required: + - device + properties: + device: + type: object + RemoteConnectUserDeviceResponse: + required: + - remoteConnectUrl + - serial + properties: + remoteConnectUrl: + type: string + serial: + type: string + AddUserDevicePayload: + description: payload object for adding device to user + required: + - serial + properties: + serial: + description: Device Serial + type: string + timeout: + description: Device timeout in ms. If device is kept idle for this period, it will be automatically disconnected. Default is provider group timeout + type: integer + ErrorResponse: + required: + - message + properties: + message: + type: string +securityDefinitions: + accessTokenAuth: + type: apiKey + name: authorization + in: header diff --git a/lib/units/api/swagger/api_v1_generated.json b/lib/units/api/swagger/api_v1_generated.json new file mode 100644 index 00000000..5d4b6c70 --- /dev/null +++ b/lib/units/api/swagger/api_v1_generated.json @@ -0,0 +1,499 @@ +{ + "swagger": "2.0", + "info": { + "version": "2.0.0", + "title": "Smartphone Test Farm", + "description": "Control and manages real Smartphone devices from browser and restful apis", + "license": { + "name": "Apache-2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + }, + "contact": { + "name": "STF Team", + "email": "contact@openstf.io", + "url": "http://openstf.io/" + } + }, + "basePath": "/api/v1", + "schemes": [ + "http", + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + { + "name": "user", + "description": "User Operations" + }, + { + "name": "devices", + "description": "Device Operations" + } + ], + "paths": { + "/user": { + "get": { + "summary": "User Profile", + "description": "The User Profile endpoint returns information about current authorized user", + "operationId": "getUser", + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "Current User Profile information", + "schema": { + "$ref": "#/definitions/UserResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/user/devices": { + "get": { + "summary": "User Devices", + "description": "The User Devices endpoint returns device list owner by current authorized user", + "operationId": "getUserDevices", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "fields", + "in": "query", + "description": "Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Current User Devices List", + "schema": { + "$ref": "#/definitions/DeviceListResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + }, + "post": { + "summary": "Add a device to a user", + "description": "The User Devices endpoint will request stf server for a new device.", + "operationId": "addUserDevice", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "device", + "in": "body", + "description": "Device to add", + "required": true, + "schema": { + "$ref": "#/definitions/AddUserDevicePayload" + } + } + ], + "responses": { + "200": { + "description": "Add User Device Status" + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/user/devices/{serial}": { + "get": { + "summary": "User Device", + "description": "The devices enpoint return information about device owned by user", + "operationId": "getUserDeviceBySerial", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "serial", + "in": "path", + "description": "Device Serial", + "required": true, + "type": "string" + }, + { + "name": "fields", + "in": "query", + "description": "Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Device Information owned by user", + "schema": { + "$ref": "#/definitions/DeviceResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + }, + "delete": { + "summary": "Delete User Device", + "description": "The User Devices endpoint will request for device release from stf server. It will return request accepted if device is being used by current user", + "operationId": "deleteUserDeviceBySerial", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "serial", + "in": "path", + "description": "Device Serial", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Delete User Device Status" + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/user/devices/{serial}/remoteConnect": { + "post": { + "summary": "Remote Connect", + "description": "The device connect endpoint will request stf server to connect remotely", + "operationId": "remoteConnectUserDeviceBySerial", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "serial", + "in": "path", + "description": "Device Serial", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Remote Connect User Device Request Status", + "schema": { + "$ref": "#/definitions/RemoteConnectUserDeviceResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + }, + "delete": { + "summary": "Remote Disconnect", + "description": "The device connect endpoint will request stf server to disconnect remotely", + "operationId": "remoteDisconnectUserDeviceBySerial", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "serial", + "in": "path", + "description": "Device Serial", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Remote Disonnect User Device Request Status" + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/user/accessTokens": { + "get": { + "summary": "Access Tokens", + "description": "The Access Tokens endpoints returns titles of all the valid access tokens", + "operationId": "getUserAccessTokens", + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "Access Tokens titles", + "schema": { + "$ref": "#/definitions/AccessTokensResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/devices": { + "get": { + "summary": "Device List", + "description": "The devices endpoint return list of all the STF devices including Disconnected and Offline", + "operationId": "getDevices", + "tags": [ + "devices" + ], + "parameters": [ + { + "name": "fields", + "in": "query", + "description": "Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "List of Devices", + "schema": { + "$ref": "#/definitions/DeviceListResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/devices/{serial}": { + "get": { + "summary": "Device Information", + "description": "The device enpoint return information about a single device", + "operationId": "getDeviceBySerial", + "tags": [ + "devices" + ], + "parameters": [ + { + "name": "serial", + "in": "path", + "description": "Device Serial", + "required": true, + "type": "string" + }, + { + "name": "fields", + "in": "query", + "description": "Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Device Information", + "schema": { + "$ref": "#/definitions/DeviceResponse" + } + }, + "default": { + "description": "Unexpected Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "accessTokenAuth": [] + } + ] + } + }, + "/swagger.json": {} + }, + "definitions": { + "UserResponse": { + "required": [ + "user" + ], + "properties": { + "user": { + "type": "object" + } + } + }, + "AccessTokensResponse": { + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DeviceListResponse": { + "required": [ + "devices" + ], + "properties": { + "devices": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "DeviceResponse": { + "required": [ + "device" + ], + "properties": { + "device": { + "type": "object" + } + } + }, + "RemoteConnectUserDeviceResponse": { + "required": [ + "remoteConnectUrl", + "serial" + ], + "properties": { + "remoteConnectUrl": { + "type": "string" + }, + "serial": { + "type": "string" + } + } + }, + "AddUserDevicePayload": { + "description": "payload object for adding device to user", + "required": [ + "serial" + ], + "properties": { + "serial": { + "description": "Device Serial", + "type": "string" + }, + "timeout": { + "description": "Device timeout in ms. If device is kept idle for this period, it will be automatically disconnected. Default is provider group timeout", + "type": "integer" + } + } + }, + "ErrorResponse": { + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "accessTokenAuth": { + "type": "apiKey", + "name": "authorization", + "in": "header" + } + } +} diff --git a/lib/units/app/index.js b/lib/units/app/index.js index e355bd90..9f19dd4b 100644 --- a/lib/units/app/index.js +++ b/lib/units/app/index.js @@ -9,13 +9,10 @@ var bodyParser = require('body-parser') var serveFavicon = require('serve-favicon') var serveStatic = require('serve-static') var csrf = require('csurf') -var Promise = require('bluebird') var compression = require('compression') var logger = require('../../util/logger') var pathutil = require('../../util/pathutil') -var dbapi = require('../../db/api') -var datautil = require('../../util/datautil') var auth = require('./middleware/auth') var deviceIconMiddleware = require('./middleware/device-icons') @@ -124,105 +121,6 @@ module.exports = function(options) { res.send('var GLOBAL_APPSTATE = ' + JSON.stringify(state)) }) - app.get('/app/api/v1/user', function(req, res) { - res.json({ - success: true - , user: req.user - }) - }) - - app.get('/app/api/v1/group', function(req, res) { - dbapi.loadGroup(req.user.email) - .then(function(cursor) { - return Promise.promisify(cursor.toArray, cursor)() - .then(function(list) { - list.forEach(function(device) { - datautil.normalize(device, req.user) - }) - res.json({ - success: true - , devices: list - }) - }) - }) - .catch(function(err) { - log.error('Failed to load group: ', err.stack) - res.json(500, { - success: false - }) - }) - }) - - app.get('/app/api/v1/devices', function(req, res) { - dbapi.loadDevices() - .then(function(cursor) { - return Promise.promisify(cursor.toArray, cursor)() - .then(function(list) { - list.forEach(function(device) { - datautil.normalize(device, req.user) - }) - - res.json({ - success: true - , devices: list - }) - }) - }) - .catch(function(err) { - log.error('Failed to load device list: ', err.stack) - res.json(500, { - success: false - }) - }) - }) - - app.get('/app/api/v1/devices/:serial', function(req, res) { - dbapi.loadDevice(req.params.serial) - .then(function(device) { - if (device) { - datautil.normalize(device, req.user) - res.json({ - success: true - , device: device - }) - } - else { - res.json(404, { - success: false - }) - } - }) - .catch(function(err) { - log.error('Failed to load device "%s": ', req.params.serial, err.stack) - res.json(500, { - success: false - }) - }) - }) - - app.get('/app/api/v1/accessTokens', function(req, res) { - dbapi.loadAccessTokens(req.user.email) - .then(function(cursor) { - return Promise.promisify(cursor.toArray, cursor)() - .then(function(list) { - var titles = [] - list.forEach(function(token) { - titles.push(token.title) - }) - res.json({ - success: true - , titles: titles - }) - }) - }) - .catch(function(err) { - log.error('Failed to load tokens: ', err.stack) - res.json(500, { - success: false - }) - }) - }) - server.listen(options.port) log.info('Listening on port %d', options.port) } diff --git a/lib/units/device/plugins/connect.js b/lib/units/device/plugins/connect.js index d058b176..90bf3f79 100644 --- a/lib/units/device/plugins/connect.js +++ b/lib/units/device/plugins/connect.js @@ -142,6 +142,16 @@ module.exports = syrup.serial() channel , reply.okay(url) ]) + + // Update DB + push.send([ + channel + , wireutil.envelope(new wire.ConnectStartedMessage( + options.serial + , url + )) + ]) + log.important('Remote Connect Started for device "%s" at "%s"', options.serial, url) }) .catch(function(err) { log.error('Unable to start remote connect service', err.stack) @@ -159,6 +169,14 @@ module.exports = syrup.serial() channel , reply.okay() ]) + // Update DB + push.send([ + channel + , wireutil.envelope(new wire.ConnectStoppedMessage( + options.serial + )) + ]) + log.important('Remote Connect Stopped for device "%s"', options.serial) }) .catch(function(err) { log.error('Failed to stop connect service', err.stack) diff --git a/lib/units/poorxy/index.js b/lib/units/poorxy/index.js index 1141d1b1..74f44fc5 100644 --- a/lib/units/poorxy/index.js +++ b/lib/units/poorxy/index.js @@ -51,6 +51,13 @@ module.exports = function(options) { }) }) + ;['/api/*'].forEach(function(route) { + app.all(route, function(req, res) { + proxy.web(req, res, { + target: options.apiUrl + }) + }) + }) app.use(function(req, res) { proxy.web(req, res, { target: options.appUrl diff --git a/lib/units/processor/index.js b/lib/units/processor/index.js index 7b022d91..886fcdbb 100644 --- a/lib/units/processor/index.js +++ b/lib/units/processor/index.js @@ -165,6 +165,14 @@ module.exports = function(options) { ) }) }) + .on(wire.ConnectStartedMessage, function(channel, message, data) { + dbapi.setDeviceConnectUrl(message.serial, message.url) + appDealer.send([channel, data]) + }) + .on(wire.ConnectStoppedMessage, function(channel, message, data) { + dbapi.unsetDeviceConnectUrl(message.serial) + appDealer.send([channel, data]) + }) .on(wire.JoinGroupMessage, function(channel, message, data) { dbapi.setDeviceOwner(message.serial, message.owner) appDealer.send([channel, data]) diff --git a/lib/util/datautil.js b/lib/util/datautil.js index dfbe6b00..1220a0b5 100644 --- a/lib/util/datautil.js +++ b/lib/util/datautil.js @@ -54,10 +54,22 @@ datautil.applyOwner = function(device, user) { return device } +// Only owner can see this information +datautil.applyOwnerOnlyInfo = function(device, user) { + if (device.owner && device.owner.email === user.email) { + // No-op + } + else { + device.remoteConnect = false + device.remoteConnectUrl = null + } +} + datautil.normalize = function(device, user) { datautil.applyData(device) datautil.applyBrowsers(device) datautil.applyOwner(device, user) + datautil.applyOwnerOnlyInfo(device, user) if (!device.present) { device.owner = null } diff --git a/lib/util/deviceutil.js b/lib/util/deviceutil.js new file mode 100644 index 00000000..7b125d9a --- /dev/null +++ b/lib/util/deviceutil.js @@ -0,0 +1,20 @@ +var logger = require('./logger') + +var log = logger.createLogger('util:deviceutil') + +var deviceutil = module.exports = Object.create(null) + +deviceutil.isOwnedByUser = function(device, user) { + return device.present && + device.ready && + device.owner && + device.owner.email === user.email && + device.using +} + +deviceutil.isAddable = function(device, user) { + return device.present && + device.ready && + !device.using && + !device.owner +} diff --git a/lib/wire/wire.proto b/lib/wire/wire.proto index 868e507e..52215f09 100644 --- a/lib/wire/wire.proto +++ b/lib/wire/wire.proto @@ -76,6 +76,17 @@ enum MessageType { ReverseForwardsEvent = 72; FileSystemListMessage = 81; FileSystemGetMessage = 82; + ConnectStartedMessage = 92; + ConnectStoppedMessage = 93; +} + +message ConnectStartedMessage { + required string serial = 1; + required string url = 2; +} + +message ConnectStoppedMessage { + required string serial = 1; } message FileSystemListMessage { diff --git a/package.json b/package.json index 98ce8ffe..8dda937f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stf", - "version": "1.2.0", + "version": "2.0.0", "description": "Smartphone Test Farm", "keywords": [ "adb", @@ -82,6 +82,7 @@ "stf-device-db": "^1.2.0", "stf-syrup": "^1.0.0", "stf-wiki": "^1.0.0", + "swagger-express-mw": "^0.6.0", "temp": "^0.8.1", "transliteration": "^0.1.1", "utf-8-validate": "^1.2.1", diff --git a/res/app/components/stf/device/device-service.js b/res/app/components/stf/device/device-service.js index 95aeb632..a5672a46 100644 --- a/res/app/components/stf/device/device-service.js +++ b/res/app/components/stf/device/device-service.js @@ -165,7 +165,7 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi , digest: false }) - oboe('/app/api/v1/devices') + oboe('/api/v1/devices') .node('devices[*]', function(device) { tracker.add(device) }) @@ -181,7 +181,7 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi , digest: true }) - oboe('/app/api/v1/group') + oboe('/api/v1/user/devices') .node('devices[*]', function(device) { tracker.add(device) }) @@ -190,7 +190,7 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi } deviceService.load = function(serial) { - return $http.get('/app/api/v1/devices/' + serial) + return $http.get('/api/v1/devices/' + serial) .then(function(response) { return response.data.device }) diff --git a/res/app/components/stf/tokens/access-token-service.js b/res/app/components/stf/tokens/access-token-service.js index 38b9db6f..214aa8cf 100644 --- a/res/app/components/stf/tokens/access-token-service.js +++ b/res/app/components/stf/tokens/access-token-service.js @@ -6,7 +6,7 @@ module.exports = function AccessTokenServiceFactory( var AccessTokenService = {} AccessTokenService.getAccessTokens = function() { - return $http.get('/app/api/v1/accessTokens') + return $http.get('/api/v1/user/accessTokens') } AccessTokenService.generateAccessToken = function(title) {