Merge pull request #219 from openstf/2.0.0

STF REST API (v2.0.0)
This commit is contained in:
Vishal Banthia
2016-07-28 22:09:25 +05:30
committed by GitHub
23 changed files with 2243 additions and 114 deletions

View File

@@ -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:

View File

@@ -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

458
doc/API.md Normal file
View File

@@ -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!
```

View File

@@ -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;
}

View File

@@ -758,6 +758,9 @@ program
.option('-u, --app-url <url>'
, 'URL to app'
, String)
.option('-i, --api-url <url>'
, 'URL to api'
, String)
.option('-a, --auth-url <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>'
, 'port (or $PORT)'
, Number
, process.env.PORT || 7106)
.option('-i, --ssid <ssid>'
, 'session SSID (or $SSID)'
, String
, process.env.SSID || 'ssid')
.option('-s, --secret <secret>'
, 'secret (or $SECRET)'
, String
, process.env.SECRET)
.option('-c, --connect-push <endpoint>'
, 'push endpoint'
, cliutil.list)
.option('-u, --connect-sub <endpoint>'
, '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 <port>'
, 'api port'
, Number
, 7106)
.option('--websocket-port <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'

View File

@@ -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

View File

@@ -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...

View File

@@ -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
})
})
}

View File

@@ -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
})
})
}

View File

@@ -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'
})
}
}

109
lib/units/api/index.js Normal file
View File

@@ -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)
}

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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])

View File

@@ -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
}

20
lib/util/deviceutil.js Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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
})

View File

@@ -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) {