Skip to content

Commit

Permalink
API: add path support (navigation) (#352)
Browse files Browse the repository at this point in the history
  • Loading branch information
ottodevs authored and sohkai committed Sep 2, 2019
1 parent 6a67cae commit 897f7be
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 3 deletions.
18 changes: 17 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,22 @@ To get information about just the current app, use `currentApp()` instead.

Returns **[Observable](https://rxjs-dev.firebaseapp.com/api/index/class/Observable)**: A multi-emission observable that emits an array of installed application objects every time a change to the installed applications is detected. Each object contains the same details as `currentApp()`.

### path

Get the current path for the app over time. Useful with `requestPath()` to request and respond to in-app navigation changes.

Returns **[Observable](https://rxjs-dev.firebaseapp.com/api/index/class/Observable)**: A multi-emission observable that emits a string for the app's current path every time the path changes.

### requestPath

Request the current app be allowed to navigate to a different path. Different clients may behave differently, such as requesting user interaction, but all clients _should_ only allow an app to change its path if it is currently visible to users.

#### Parameters

- `path` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)**: The path to navigate to

Returns **[Observable](https://rxjs-dev.firebaseapp.com/api/index/class/Observable)**: A single-emission observable that emits `null` on success or an error if the path request was rejected.

### call

Perform a read-only call on the app's smart contract.
Expand Down Expand Up @@ -346,7 +362,7 @@ Perform a signature using the [personal_sign](https://web3js.readthedocs.io/en/1

- `message` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)**: The message to sign

Returns **[Observable](https://rxjs-dev.firebaseapp.com/api/index/class/Observable)**: A single-emission observable that emits the result of the signature. Errors if the user chose not to sign the message.
Returns **[Observable](https://rxjs-dev.firebaseapp.com/api/index/class/Observable)**: A single-emission observable that emits the signature hash on success or an error if the user chose not to sign the message.

#### Examples

Expand Down
30 changes: 29 additions & 1 deletion packages/aragon-api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,34 @@ export class AppProxy {
)
}

/**
* Get current path for the app. Useful for in-app routing and navigation.
*
* @return {Observable} Multi-emission Observable that emits the app's current path every time a change is detected.
*/
path () {
return this.rpc.sendAndObserveResponses(
'path',
['observe']
).pipe(
pluck('result')
)
}

/**
* Request a new path.
*
* @return {Observable} Single-emission Observable that emits if the path request succeeded and errors if rejected
*/
requestPath (path) {
return this.rpc.sendAndObserveResponse(
'path',
['modify', path]
).pipe(
pluck('result')
)
}

/**
* Resolve an address' identity, using the highest priority provider.
*
Expand All @@ -163,7 +191,7 @@ export class AppProxy {
* The request is typically handled by the aragon client.
*
* @param {string} address Address to modify.
* @return {Observable} Single-emission Observable that emits if the modification succeeded or cancelled by the user
* @return {Observable} Single-emission Observable that emits if the modification succeeded and errors if cancelled by the user
*/
requestAddressIdentityModification (address) {
return this.rpc.sendAndObserveResponse(
Expand Down
62 changes: 62 additions & 0 deletions packages/aragon-api/src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,68 @@ test('should send an identify request', t => {
t.true(instanceStub.rpc.send.calledOnceWith('identify', ['ANT']))
})

test('should send a path request and observe the response', t => {
t.plan(3)
// arrange
const pathFn = Index.AppProxy.prototype.path
const observable = of(
{
jsonrpc: '2.0',
id: 'uuid1',
result: 'path1'
}, {
jsonrpc: '2.0',
id: 'uuid1',
result: 'path2'
}
)
const instanceStub = {
rpc: {
// Mimic behaviour of @aragon/rpc-messenger
sendAndObserveResponses: createDeferredStub(observable)
}
}
// act
const result = pathFn.call(instanceStub)
// assert
let emitIndex = 0
subscribe(result, value => {
if (emitIndex === 0) {
t.deepEqual(value, 'path1')
} else if (emitIndex === 1) {
t.deepEqual(value, 'path2')
} else {
t.fail('too many emissions')
}

emitIndex++
})
t.true(instanceStub.rpc.sendAndObserveResponses.calledOnceWith('path', ['observe']))
})

test('should send a path modification request', t => {
t.plan(2)
// arrange
const path = 'new_path'
const requestPathFn = Index.AppProxy.prototype.requestPath
const observable = of({
jsonrpc: '2.0',
id: 'uuid1',
result: null
})
const instanceStub = {
rpc: {
// Mimic behaviour of @aragon/rpc-messenger
sendAndObserveResponse: createDeferredStub(observable)
}
}
// act
const result = requestPathFn.call(instanceStub, path)
// assert
subscribe(result, value => t.is(value, null))
t.true(instanceStub.rpc.sendAndObserveResponse.calledOnceWith('path', ['modify', path]))
})

test('should return the events observable', t => {
t.plan(2)
// arrange
Expand Down
44 changes: 44 additions & 0 deletions packages/aragon-wrapper/src/apps/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { BehaviorSubject } from 'rxjs'
import { first } from 'rxjs/operators'

function ensureContext (apps, appAddress, context) {
let app = apps.get(appAddress)
if (!app) {
app = new Map()
apps.set(appAddress, app)
}

let appContext = app.get(context)
if (!appContext) {
appContext = new BehaviorSubject(null)
app.set(context, appContext)
}

return appContext
}

export default class AppContextPool {
#apps = new Map()

hasApp (appAddress) {
return this.#apps.has(appAddress)
}

async get (appAddress, context) {
const app = this.#apps.get(appAddress)
if (!app || !app.has(context)) {
return null
}
return app.get(context).pipe(first()).toPromise()
}

observe (appAddress, context) {
const appContext = ensureContext(this.#apps, appAddress, context)
return appContext
}

set (appAddress, context, value) {
const appContext = ensureContext(this.#apps, appAddress, context)
appContext.next(value)
}
}
62 changes: 62 additions & 0 deletions packages/aragon-wrapper/src/apps/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import test from 'ava'
import AppContextPool from './index'

test('AppContextPool starts empty', async (t) => {
// arrange
const appAddress = '0x12'
// act
const pool = new AppContextPool()
// assert
t.false(pool.hasApp(appAddress))
})

test('AppContextPool can create new app contexts', async (t) => {
// arrange
const appAddress = '0x12'
// act
const pool = new AppContextPool()
pool.set(appAddress, 'path', '/vote')
// assert
t.true(pool.hasApp(appAddress))
})

test('AppContextPool can read and write values to app context', async (t) => {
// arrange
const appAddress = '0x12'
// act
const pool = new AppContextPool()
pool.set(appAddress, 'first', 'first value')
pool.set(appAddress, 'second', 'first value')
pool.set(appAddress, 'second', 'second value')
// assert
t.is(await pool.get(appAddress, 'first'), 'first value')
t.is(await pool.get(appAddress, 'second'), 'second value')
})

test('AppContextPool can observe values from app context', async (t) => {
// arrange
const appAddress = '0x12'
const contextKey = 'key'
// act
const pool = new AppContextPool()
const observedContext = pool.observe(appAddress, contextKey)
pool.set(appAddress, contextKey, 'first value') // starting value
// assert
let counter = 0
observedContext.subscribe(val => {
if (counter === 0) {
t.is(val, 'first value')
} else if (counter === 1) {
t.is(val, 'second value')
} else if (counter === 2) {
t.is(val, 'third value')
} else {
t.fail('too many emissions')
}
counter++
})

// Emit after subscribed
pool.set(appAddress, contextKey, 'second value')
pool.set(appAddress, contextKey, 'third value')
})
51 changes: 50 additions & 1 deletion packages/aragon-wrapper/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Messenger from '@aragon/rpc-messenger'
import * as handlers from './rpc/handlers'

// Utilities
import AppContextPool from './apps'
import apm, { getApmInternalAppInfo } from './core/apm'
import { makeRepoProxy, getAllRepoVersions, getRepoVersionById } from './core/apm/repo'
import {
Expand Down Expand Up @@ -196,6 +197,9 @@ export default class Aragon {
// Set up cache
this.cache = new Cache(daoAddress)

// Set up app contexts
this.appContextPool = new AppContextPool()

this.defaultGasPriceFn = options.defaultGasPriceFn
}

Expand Down Expand Up @@ -228,6 +232,7 @@ export default class Aragon {
this.initForwarders()
this.initAppIdentifiers()
this.initNetwork()
this.pathIntents = new Subject()
this.transactions = new Subject()
this.signatures = new Subject()
}
Expand Down Expand Up @@ -991,7 +996,7 @@ export default class Aragon {
* which listens and handles `this.identityIntents`
*
* @param {string} address Address to modify
* @return {Promise} Reolved by the handler of identityIntents
* @return {Promise} Resolved by the handler of identityIntents
*/
requestAddressIdentityModification (address) {
const providerName = 'local' // TODO - get provider
Expand Down Expand Up @@ -1046,6 +1051,49 @@ export default class Aragon {
})
}

/**
* Request an app's path be changed.
*
* @param {string} appAddress
* @param {string} path
* @return {Promise} Succeeds if path request was allowed
*/
async requestAppPath (appAddress, path) {
if (typeof path !== 'string') {
throw new Error('Path must be a string')
}

if (!await this.getApp(appAddress)) {
throw new Error(`Cannot request path for non-installed app: ${appAddress}`)
}

return new Promise((resolve, reject) => {
this.pathIntents.next({
appAddress,
path,
resolve,
reject (err) {
reject(err || new Error('The path was rejected'))
}
})
})
}

/**
* Set an app's path.
*
* @param {string} appAddress
* @param {string} path
* @return {void}
*/
setAppPath (appAddress, path) {
if (typeof path !== 'string') {
throw new Error('Path must be a string')
}

this.appContextPool.set(appAddress, 'path', path)
}

/**
* Run an app.
*
Expand Down Expand Up @@ -1100,6 +1148,7 @@ export default class Aragon {
handlers.createRequestHandler(request$, 'describe_script', handlers.describeScript),
handlers.createRequestHandler(request$, 'get_apps', handlers.getApps),
handlers.createRequestHandler(request$, 'network', handlers.network),
handlers.createRequestHandler(request$, 'path', handlers.path),
handlers.createRequestHandler(request$, 'web3_eth', handlers.web3Eth),

// Contract handlers
Expand Down
2 changes: 2 additions & 0 deletions packages/aragon-wrapper/src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import sinon from 'sinon'
import proxyquire from 'proxyquire'
import { Subject, empty, of, from } from 'rxjs'
import { first } from 'rxjs/operators'
import * as apps from './apps'
import * as configurationKeys from './configuration/keys'
import * as apm from './core/apm'
import { getCacheKey } from './utils'
Expand Down Expand Up @@ -44,6 +45,7 @@ test.beforeEach(t => {
}
const Aragon = proxyquire.noCallThru().load('./index', {
'@aragon/rpc-messenger': messengerConstructorStub,
'./apps': apps,
'./core/aragonOS': aragonOSCoreStub,
'./core/apm': Object.assign(apm, apmCoreStub),
'./configuration': configurationStub,
Expand Down
1 change: 1 addition & 0 deletions packages/aragon-wrapper/src/rpc/handlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export { default as cache } from './cache'
export { default as describeScript } from './describe-script'
export { default as getApps } from './get-apps'
export { default as network } from './network'
export { default as path } from './path'
export { default as web3Eth } from './web3-eth'

export { default as intent } from './intent'
Expand Down
14 changes: 14 additions & 0 deletions packages/aragon-wrapper/src/rpc/handlers/path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default function (request, proxy, wrapper) {
const [operation] = request.params

if (operation === 'observe') {
return wrapper.appContextPool.observe(proxy.address, 'path')
}
if (operation === 'modify') {
return wrapper.requestAppPath(proxy.address, request.params[1])
}

return Promise.reject(
new Error('Invalid path operation')
)
}

0 comments on commit 897f7be

Please sign in to comment.