Skip to content

Commit

Permalink
feat: add handle function and stdio server
Browse files Browse the repository at this point in the history
  • Loading branch information
nokome committed Nov 25, 2018
1 parent 9c15f83 commit 338c385
Show file tree
Hide file tree
Showing 8 changed files with 451 additions and 104 deletions.
228 changes: 127 additions & 101 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"semantic-release-cli": "^4.0.9",
"through2": "^3.0.0",
"ts-jest": "^23.10.4",
"ts-node": "^7.0.1",
"tslint": "^5.11.0",
"tslint-config-standard": "^8.0.1",
"typedoc": "^0.13.0",
Expand Down
7 changes: 5 additions & 2 deletions src/export.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import import_ from './import'
import Thing from './Thing'

/**
Expand All @@ -6,10 +7,12 @@ import Thing from './Thing'
* @param thing The thing to be exported
* @param format The format, as a MIME type, to export to e.g. `text/html`
*/
export default function export_ (thing: Thing, format: string= 'application/ld+json'): string {
export default function export_ (thing: string | object | Thing, format: string= 'application/ld+json'): string {
if (!(thing instanceof Thing)) thing = import_(thing)

switch (format) {
case 'application/ld+json':
return exportJsonLd(thing)
return exportJsonLd(thing as Thing)
default:
throw Error(`Unhandled export format: ${format}`)
}
Expand Down
204 changes: 204 additions & 0 deletions src/handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { default as export_, exportObject } from './export'
import build from './build'
import compile from './compile'
import convert from './convert'
import execute from './execute'
import import_ from './import'
import manifest from './manifest'
import Thing from './Thing'

/**
* A JSON-RPC 2.0 request
*
* @see {@link https://www.jsonrpc.org/specification#request_object}
*/
class Request {
/**
* A string specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".
*/
jsonrpc: string = '2.0'

/**
* A string containing the name of the method to be invoked.
* Method names that begin with the word rpc followed by a period character
* (U+002E or ASCII 46) are reserved for rpc-internal methods and extensions and
* MUST NOT be used for anything else.
*/
method?: string

/**
* A structured value that holds the parameter values to be used during the
* invocation of the method.This member MAY be omitted.
*/
params?: {[key: string]: any} | any[]

/**
* An identifier established by the Client that MUST contain a string, number, or
* NULL value if included. If it is not included it is assumed to be a notification.
* The value SHOULD normally not be Null and numbers SHOULD NOT contain fractional
* parts. The Server MUST reply with the same value in the Response object if included.
* This member is used to correlate the context between the two objects.
*/
id?: string | number | null
}

/**
* A JSON-RPC 2.0 response
*
* @see {@link https://www.jsonrpc.org/specification#response_object}
*/
class Response {
/**
* A string specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".
*/
jsonrpc: string = '2.0'

/**
* This member is REQUIRED on success.
* This member MUST NOT exist if there was an error invoking the method.
* The value of this member is determined by the method invoked on the Server.
*/
result?: any

/**
* This member is REQUIRED on error.
* This member MUST NOT exist if there was no error triggered during invocation.
* The value for this member MUST be an Object as defined in section 5.1.
*/
error?: ResponseError

/**
* This member is REQUIRED.
* It MUST be the same as the value of the id member in the Request Object.
* If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.
*/
id: string | number | null = null

constructor (result?: any, error?: ResponseError, id: string | number | null = null) {
this.result = result
this.error = error
this.id = id
}
}

/**
* A JSON-RPC 2.0 response error
*
* @see {@link https://www.jsonrpc.org/specification#error_object}
*/
class ResponseError {
/**
* A Number that indicates the error type that occurred.
* This MUST be an integer.
*/
code: number

/**
* A String providing a short description of the error.
* The message SHOULD be limited to a concise single sentence.
*/
message: string

/**
* A Primitive or Structured value that contains additional information about the error.
* This may be omitted.
* The value of this member is defined by the Server (e.g. detailed error information,
* nested errors etc.).
*/
data?: any

constructor (code: number, message: string, data?: any) {
this.code = code
this.message = message
this.data = data
}
}

/**
* Handle a JSON-RPC 2,0 request
*
* @see {@link Request}
*
* @param json A JSON-PRC request
* @returns A JSON-RPC response
*/
export default function handle (json: string): string {
let request: Request
const response = new Response()

// Extract a parameter by name from Object or by index from Array
// tslint:disable-next-line:completed-docs
function param (request: Request, index: number, name: string, required: boolean = true) {
if (!request.params) throw new ResponseError(-32600, 'Invalid request: missing "params" property')
const value = Array.isArray(request.params) ? request.params[index] : request.params[name]
if (required && value === undefined) throw new ResponseError(-32602, `Invalid params: "${name}" is missing`)
return value
}

try {
// Parse JSON into an request
try {
request = JSON.parse(json)
} catch (err) {
throw new ResponseError(-32700, 'Parse error: ' + err.message)
}

// Response must always have an id
response.id = request.id || null

if (!request.method) throw new ResponseError(-32600, 'Invalid request: missing "method" property')

let result
switch (request.method) {
case 'manifest':
result = manifest()
break
case 'import':
result = import_(
param(request, 0, 'thing'),
param(request, 1, 'format', false)
)
break
case 'export':
result = export_(
param(request, 0, 'thing'),
param(request, 1, 'format', false)
)
break
case 'convert':
result = convert(
param(request, 0, 'thing'),
param(request, 1, 'from', false),
param(request, 2, 'to', false)
)
break
case 'compile':
result = compile(
param(request, 0, 'thing'),
param(request, 1, 'format', false)
)
break
case 'build':
result = build(
param(request, 0, 'thing'),
param(request, 1, 'format', false)
)
break
case 'execute':
result = execute(
param(request, 0, 'thing'),
param(request, 1, 'format', false)
)
break
default:
throw new ResponseError(-32601, `Method not found: "${request.method}"`)
}

// Most functions return a Thing tht needs to be exported to an Object
// to include in the response JSON
response.result = (result instanceof Thing) ? exportObject(result) : result
} catch (exc) {
response.error = (exc instanceof ResponseError) ? exc : new ResponseError(-32603, `Internal error: ${exc.message}`)
}
return JSON.stringify(response)
}
16 changes: 16 additions & 0 deletions src/stdio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env node

import * as readline from 'readline'

import handle from './handle'

/**
* A JSON-RPC server using standard input/output
* for communication.
*/
export const stdio = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: ''
})
.on('line', request => console.log(handle(request)))
2 changes: 1 addition & 1 deletion tests/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Thing from '../src/Thing'
test('export:Thing', () => {
const thing = new Thing()
expect(export_(thing)).toEqual(exportJsonLd(thing))
expect(export_(thing, 'application/ld+json')).toEqual(exportJsonLd(thing))
expect(export_(exportJsonLd(thing), 'application/ld+json')).toEqual(exportJsonLd(thing))
expect(() => export_(thing, 'foo/bar')).toThrow(/^Unhandled export format: foo\/bar/)
})

Expand Down
79 changes: 79 additions & 0 deletions tests/handle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import handle from '../src/handle'
import manifest from '../src/manifest';

// @ts-ignore
function check(request, response){
expect(JSON.parse(handle(JSON.stringify(request)))).toEqual(response)
}

test('handle', () => {
check(
null,
{jsonrpc: '2.0', id: null, error: {code: -32603, message: "Internal error: Cannot read property 'id' of null"}}
)

check(
{jsonrpc: '2.0'},
{jsonrpc: '2.0', id: null, error: {code: -32600, message: 'Invalid request: missing "method" property'}}
)

check(
{jsonrpc: '2.0', id: 1},
{jsonrpc: '2.0', id: 1, error: {code: -32600, message: 'Invalid request: missing "method" property'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "foo"},
{jsonrpc: '2.0', id: 1, error: {code: -32601, message: 'Method not found: "foo"'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "manifest"},
{jsonrpc: '2.0', id: 1, result: manifest()}
)

check(
{jsonrpc: '2.0', id: 1, method: "import"},
{jsonrpc: '2.0', id: 1, error: {code: -32600, message: 'Invalid request: missing "params" property'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "import", params: []},
{jsonrpc: '2.0', id: 1, error: {code: -32602, message: 'Invalid params: "thing" is missing'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "import", params: ['{"type": "Thing"}']},
{jsonrpc: '2.0', id: 1, result: {type: 'Thing'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "import", params: {thing: '{"type": "Thing"}'}},
{jsonrpc: '2.0', id: 1, result: {type: 'Thing'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "export", params: {thing: '{"type": "Thing"}'}},
{jsonrpc: '2.0', id: 1, result: '{"@context":"https://stencila.github.io/schema/context.jsonld","type":"Thing"}'}
)

check(
{jsonrpc: '2.0', id: 1, method: "convert", params: ['{"type": "Thing"}']},
{jsonrpc: '2.0', id: 1, result: '{"@context":"https://stencila.github.io/schema/context.jsonld","type":"Thing"}'}
)

check(
{jsonrpc: '2.0', id: 1, method: "compile", params: ['{"type": "Thing"}']},
{jsonrpc: '2.0', id: 1, result: {type: 'Thing'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "build", params: ['{"type": "Thing"}']},
{jsonrpc: '2.0', id: 1, result: {type: 'Thing'}}
)

check(
{jsonrpc: '2.0', id: 1, method: "execute", params: ['{"type": "Thing"}']},
{jsonrpc: '2.0', id: 1, result: {type: 'Thing'}}
)
})
18 changes: 18 additions & 0 deletions tests/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
test('stdio', () => {
const spy = jest.spyOn(console, 'log')

const {stdio} = require('../src/stdio')

stdio.emit('line', 'foo')
stdio.emit('line', '{}')
stdio.emit('line', '{"id":1, "method":"import", "params":[{"type":"Thing","name":"Joe"}]}')

stdio.on('close', () => {
expect(spy.mock.calls).toEqual([
['{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error: Unexpected token o in JSON at position 1"}}'],
['{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request: missing \\"method\\" property"}}'],
['{"jsonrpc":"2.0","id":1,"result":{"type":"Thing","name":"Joe"}}']
])
})
stdio.close()
})

0 comments on commit 338c385

Please sign in to comment.