diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c60afe --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright 2014, the Dart project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d09d0f --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +A library that implements the [JSON-RPC 2.0 spec][spec]. + +[spec]: http://www.jsonrpc.org/specification + +## Server + +A JSON-RPC 2.0 server exposes a set of methods that can be called by clients. +These methods can be registered using `Server.registerMethod`: + +```dart +import "package:json_rpc_2/json_rpc_2.dart" as json_rpc; + +var server = new json_rpc.Server(); + +// Any string may be used as a method name. JSON-RPC 2.0 methods are +// case-sensitive. +var i = 0; +server.registerMethod("count", () { + // Just return the value to be sent as a response to the client. This can be + // anything JSON-serializable, or a Future that completes to something + // JSON-serializable. + return i++; +}); + +// Methods can take parameters. They're presented as a [Parameters] object which +// makes it easy to validate that the expected parameters exist. +server.registerMethod("echo", (params) { + // If the request doesn't have a "message" parameter, this will automatically + // send a response notifying the client that the request was invalid. + return params.getNamed("message"); +}); + +// [Parameters] has methods for verifying argument types. +server.registerMethod("subtract", (params) { + // If "minuend" or "subtrahend" aren't numbers, this will reject the request. + return params.getNum("minuend") - params.getNum("subtrahend"); +}); + +// [Parameters] also supports optional arguments. +server.registerMethod("sort", (params) { + var list = params.getList("list"); + list.sort(); + if (params.getBool("descending", orElse: () => false)) { + return params.list.reversed; + } else { + return params.list; + } +}); + +// A method can send an error response by throwing a `json_rpc.RpcException`. +// Any positive number may be used as an application-defined error code. +const DIVIDE_BY_ZERO = 1; +server.registerMethod("divide", (params) { + var divisor = params.getNum("divisor"); + if (divisor == 0) { + throw new json_rpc.RpcException(DIVIDE_BY_ZERO, "Cannot divide by zero."); + } + + return params.getNum("dividend") / divisor; +}); +``` + +Once you've registered your methods, you can handle requests with +`Server.parseRequest`: + +```dart +import 'dart:io'; + +WebSocket.connect('ws://localhost:4321').then((socket) { + socket.listen((message) { + server.parseRequest(message).then((response) { + if (response != null) socket.add(response); + }); + }); +}); +``` + +If you're communicating with objects that haven't been serialized to a string, +you can also call `Server.handleRequest` directly: + +```dart +import 'dart:isolate'; + +var receive = new ReceivePort(); +Isolate.spawnUri('path/to/client.dart', [], receive.sendPort).then((_) { + receive.listen((message) { + server.handleRequest(message['request']).then((response) { + if (response != null) message['respond'].send(response); + }); + }); +}) +``` + +## Client + +Currently this package does not contain an implementation of a JSON-RPC 2.0 +client. + diff --git a/lib/error_code.dart b/lib/error_code.dart new file mode 100644 index 0000000..40d6733 --- /dev/null +++ b/lib/error_code.dart @@ -0,0 +1,36 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Error codes defined in the [JSON-RPC 2.0 specificiation][spec]. +/// +/// These codes are generally used for protocol-level communication. Most of +/// them shouldn't be used by the application. Those that should have +/// convenience constructors in [RpcException]. +/// +/// [spec]: http://www.jsonrpc.org/specification#error_object +library json_rpc_2.error_code; + +/// An error code indicating that invalid JSON was received by the server. +const PARSE_ERROR = -32700; + +/// An error code indicating that the request JSON was invalid according to the +/// JSON-RPC 2.0 spec. +const INVALID_REQUEST = -32600; + +/// An error code indicating that the requested method does not exist or is +/// unavailable. +const METHOD_NOT_FOUND = -32601; + +/// An error code indicating that the request paramaters are invalid for the +/// requested method. +const INVALID_PARAMS = -32602; + +/// An internal JSON-RPC error. +const INTERNAL_ERROR = -32603; + +/// An unexpected error occurred on the server. +/// +/// The spec reserves the range from -32000 to -32099 for implementation-defined +/// server exceptions, but for now we only use one of those values. +const SERVER_ERROR = -32000; diff --git a/lib/json_rpc_2.dart b/lib/json_rpc_2.dart new file mode 100644 index 0000000..04e4a52 --- /dev/null +++ b/lib/json_rpc_2.dart @@ -0,0 +1,9 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2; + +export 'src/exception.dart'; +export 'src/parameters.dart'; +export 'src/server.dart'; diff --git a/lib/src/exception.dart b/lib/src/exception.dart new file mode 100644 index 0000000..fb1cd2f --- /dev/null +++ b/lib/src/exception.dart @@ -0,0 +1,65 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.exception; + +import '../error_code.dart' as error_code; + +/// An exception from a JSON-RPC server that can be translated into an error +/// response. +class RpcException implements Exception { + /// The error code. + /// + /// All non-negative error codes are available for use by application + /// developers. + final int code; + + /// The error message. + /// + /// This should be limited to a concise single sentence. Further information + /// should be supplied via [data]. + final String message; + + /// Extra application-defined information about the error. + /// + /// This must be a JSON-serializable object. If it's a [Map] without a + /// `"request"` key, a copy of the request that caused the error will + /// automatically be injected. + final data; + + RpcException(this.code, this.message, {this.data}); + + /// An exception indicating that the method named [methodName] was not found. + /// + /// This should usually be used only by fallback handlers. + RpcException.methodNotFound(String methodName) + : this(error_code.METHOD_NOT_FOUND, 'Unknown method "$methodName".'); + + /// An exception indicating that the parameters for the requested method were + /// invalid. + /// + /// Methods can use this to reject requests with invalid parameters. + RpcException.invalidParams(String message) + : this(error_code.INVALID_PARAMS, message); + + /// Converts this exception into a JSON-serializable object that's a valid + /// JSON-RPC 2.0 error response. + serialize(request) { + var modifiedData; + if (data is Map && !data.containsKey('request')) { + modifiedData = new Map.from(data); + modifiedData['request'] = request; + } else if (data == null) { + modifiedData = {'request': request}; + } + + var id = request is Map ? request['id'] : null; + if (id is! String && id is! num) id = null; + return { + 'jsonrpc': '2.0', + 'error': {'code': code, 'message': message, 'data': modifiedData}, + 'id': id + }; + } +} diff --git a/lib/src/parameters.dart b/lib/src/parameters.dart new file mode 100644 index 0000000..afc4a40 --- /dev/null +++ b/lib/src/parameters.dart @@ -0,0 +1,283 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.parameters; + +import 'dart:convert'; + +import 'exception.dart'; + +/// A wrapper for the parameters to a server method. +/// +/// JSON-RPC 2.0 allows parameters that are either a list or a map. This class +/// provides functions that not only assert that the parameters object is the +/// correct type, but also that the expected arguments exist and are themselves +/// the correct type. +/// +/// Example usage: +/// +/// server.registerMethod("subtract", (params) { +/// return params["minuend"].asNum - params["subtrahend"].asNum; +/// }); +class Parameters { + /// The name of the method that this request called. + final String method; + + /// The underlying value of the parameters object. + /// + /// If this is accessed for a [Parameter] that was not passed, the request + /// will be automatically rejected. To avoid this, use [Parameter.valueOr]. + final value; + + Parameters(this.method, this.value); + + /// Returns a single parameter. + /// + /// If [key] is a [String], the request is expected to provide named + /// parameters. If it's an [int], the request is expected to provide + /// positional parameters. Requests that don't do so will be rejected + /// automatically. + /// + /// Whether or not the given parameter exists, this returns a [Parameter] + /// object. If a parameter's value is accessed through a getter like [value] + /// or [Parameter.asNum], the request will be rejected if that parameter + /// doesn't exist. On the other hand, if it's accessed through a method with a + /// default value like [Parameter.valueOr] or [Parameter.asNumOr], the default + /// value will be returned. + Parameter operator [](key) { + if (key is int) { + _assertPositional(); + if (key < value.length) { + return new Parameter._(method, value[key], this, key); + } else { + return new _MissingParameter(method, this, key); + } + } else if (key is String) { + _assertNamed(); + if (value.containsKey(key)) { + return new Parameter._(method, value[key], this, key); + } else { + return new _MissingParameter(method, this, key); + } + } else { + throw new ArgumentError('Parameters[] only takes an int or a string, was ' + '"$key".'); + } + } + + /// Asserts that [value] exists and is a [List] and returns it. + List get asList { + _assertPositional(); + return value; + } + + /// Asserts that [value] exists and is a [Map] and returns it. + Map get asMap { + _assertNamed(); + return value; + } + + /// Asserts that [value] is a positional argument list. + void _assertPositional() { + if (value is List) return; + throw new RpcException.invalidParams('Parameters for method "$method" ' + 'must be passed by position.'); + } + + /// Asserts that [value] is a named argument map. + void _assertNamed() { + if (value is Map) return; + throw new RpcException.invalidParams('Parameters for method "$method" ' + 'must be passed by name.'); + } +} + +/// A wrapper for a single parameter to a server method. +/// +/// This provides numerous functions for asserting the type of the parameter in +/// question. These functions each have a version that asserts that the +/// parameter exists (for example, [asNum] and [asString]) and a version that +/// returns a default value if the parameter doesn't exist (for example, +/// [asNumOr] and [asStringOr]). If an assertion fails, the request is +/// automatically rejected. +/// +/// This extends [Parameters] to make it easy to access nested parameters. For +/// example: +/// +/// // "params.value" is "{'scores': {'home': [5, 10, 17]}}" +/// params['scores']['home'][2].asInt // => 17 +class Parameter extends Parameters { + // The parent parameters, used to construct [_path]. + final Parameters _parent; + + /// The key used to access [this], used to construct [_path]. + final _key; + + /// A human-readable representation of the path of getters used to get this. + /// + /// Named parameters are represented as `.name`, whereas positional parameters + /// are represented as `[index]`. For example: `"foo[0].bar.baz"`. Named + /// parameters that contain characters that are neither alphanumeric, + /// underscores, or hyphens will be JSON-encoded. For example: `"foo + /// bar"."baz.bang"`. If quotes are used for an individual component, they + /// won't be used for the entire string. + /// + /// An exception is made for single-level parameters. A single-level + /// positional parameter is just represented by the index plus one, because + /// "parameter 1" is clearer than "parameter [0]". A single-level named + /// parameter is represented by that name in quotes. + String get _path { + if (_parent is! Parameter) { + return _key is int ? (_key + 1).toString() : JSON.encode(_key); + } + + quoteKey(key) { + if (key.contains(new RegExp(r'[^a-zA-Z0-9_-]'))) return JSON.encode(key); + return key; + } + + computePath(params) { + if (params._parent is! Parameter) { + return params._key is int ? "[${params._key}]" : quoteKey(params._key); + } + + var path = computePath(params._parent); + return params._key is int ? + "$path[${params._key}]" : "$path.${quoteKey(params._key)}"; + } + + return computePath(this); + } + + /// Whether this parameter exists. + final exists = true; + + Parameter._(String method, value, this._parent, this._key) + : super(method, value); + + /// Returns [value], or [defaultValue] if this parameter wasn't passed. + valueOr(defaultValue) => value; + + /// Asserts that [value] exists and is a number and returns it. + /// + /// [asNumOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + num get asNum => _getTyped('a number', (value) => value is num); + + /// Asserts that [value] is a number and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + num asNumOr(num defaultValue) => asNum; + + /// Asserts that [value] exists and is an integer and returns it. + /// + /// [asIntOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + /// + /// Note that which values count as integers varies between the Dart VM and + /// dart2js. The value `1.0` will be considered an integer under dart2js but + /// not under the VM. + int get asInt => _getTyped('an integer', (value) => value is int); + + /// Asserts that [value] is an integer and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + /// + /// Note that which values count as integers varies between the Dart VM and + /// dart2js. The value `1.0` will be considered an integer under dart2js but + /// not under the VM. + int asIntOr(int defaultValue) => asInt; + + /// Asserts that [value] exists and is a boolean and returns it. + /// + /// [asBoolOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + bool get asBool => _getTyped('a boolean', (value) => value is bool); + + /// Asserts that [value] is a boolean and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + bool asBoolOr(bool defaultValue) => asBool; + + /// Asserts that [value] exists and is a string and returns it. + /// + /// [asStringOr] may be used to provide a default value instead of rejecting + /// the request if [value] doesn't exist. + String get asString => _getTyped('a string', (value) => value is String); + + /// Asserts that [value] is a string and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + String asStringOr(String defaultValue) => asString; + + /// Asserts that [value] exists and is a [List] and returns it. + /// + /// [asListOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + List get asList => _getTyped('an Array', (value) => value is List); + + /// Asserts that [value] is a [List] and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + List asListOr(List defaultValue) => asList; + + /// Asserts that [value] exists and is a [Map] and returns it. + /// + /// [asListOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + Map get asMap => _getTyped('an Object', (value) => value is Map); + + /// Asserts that [value] is a [Map] and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + Map asMapOr(Map defaultValue) => asMap; + + /// Get a parameter named [named] that matches [test], or the value of calling + /// [orElse]. + /// + /// [type] is used for the error message. It should begin with an indefinite + /// article. + _getTyped(String type, bool test(value)) { + if (test(value)) return value; + throw new RpcException.invalidParams('Parameter $_path for method ' + '"$method" must be $type, but was ${JSON.encode(value)}.'); + } + + void _assertPositional() { + // Throw the standard exception for a mis-typed list. + asList; + } + + void _assertNamed() { + // Throw the standard exception for a mis-typed map. + asMap; + } +} + +/// A subclass of [Parameter] representing a missing parameter. +class _MissingParameter extends Parameter { + get value { + throw new RpcException.invalidParams('Request for method "$method" is ' + 'missing required parameter $_path.'); + } + + final exists = false; + + _MissingParameter(String method, Parameters parent, key) + : super._(method, null, parent, key); + + valueOr(defaultValue) => defaultValue; + + num asNumOr(num defaultValue) => defaultValue; + + int asIntOr(int defaultValue) => defaultValue; + + bool asBoolOr(bool defaultValue) => defaultValue; + + String asStringOr(String defaultValue) => defaultValue; + + List asListOr(List defaultValue) => defaultValue; + + Map asMapOr(Map defaultValue) => defaultValue; +} diff --git a/lib/src/server.dart b/lib/src/server.dart new file mode 100644 index 0000000..d05c54f --- /dev/null +++ b/lib/src/server.dart @@ -0,0 +1,220 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.server; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:stack_trace/stack_trace.dart'; + +import '../error_code.dart' as error_code; +import 'exception.dart'; +import 'parameters.dart'; +import 'utils.dart'; + +/// A JSON-RPC 2.0 server. +/// +/// A server exposes methods that are called by requests, to which it provides +/// responses. Methods can be registered using [registerMethod] and +/// [registerFallback]. Requests can be handled using [handleRequest] and +/// [parseRequest]. +/// +/// Note that since requests can arrive asynchronously and methods can run +/// asynchronously, it's possible for multiple methods to be invoked at the same +/// time, or even for a single method to be invoked multiple times at once. +class Server { + /// The methods registered for this server. + final _methods = new Map(); + + /// The fallback methods for this server. + /// + /// These are tried in order until one of them doesn't throw a + /// [RpcException.methodNotFound] exception. + final _fallbacks = new Queue(); + + Server(); + + /// Registers a method named [name] on this server. + /// + /// [callback] can take either zero or one arguments. If it takes zero, any + /// requests for that method that include parameters will be rejected. If it + /// takes one, it will be passed a [Parameters] object. + /// + /// [callback] can return either a JSON-serializable object or a Future that + /// completes to a JSON-serializable object. Any errors in [callback] will be + /// reported to the client as JSON-RPC 2.0 errors. + void registerMethod(String name, Function callback) { + if (_methods.containsKey(name)) { + throw new ArgumentError('There\'s already a method named "$name".'); + } + + _methods[name] = callback; + } + + /// Registers a fallback method on this server. + /// + /// A server may have any number of fallback methods. When a request comes in + /// that doesn't match any named methods, each fallback is tried in order. A + /// fallback can pass on handling a request by throwing a + /// [RpcException.methodNotFound] exception. + /// + /// [callback] can return either a JSON-serializable object or a Future that + /// completes to a JSON-serializable object. Any errors in [callback] will be + /// reported to the client as JSON-RPC 2.0 errors. [callback] may send custom + /// errors by throwing an [RpcException]. + void registerFallback(callback(Parameters parameters)) { + _fallbacks.add(callback); + } + + /// Handle a request that's already been parsed from JSON. + /// + /// [request] is expected to be a JSON-serializable object representing a + /// request sent by a client. This calls the appropriate method or methods for + /// handling that request and returns a JSON-serializable response, or `null` + /// if no response should be sent. [callback] may send custom + /// errors by throwing an [RpcException]. + Future handleRequest(request) { + return syncFuture(() { + if (request is! List) return _handleSingleRequest(request); + if (request.isEmpty) { + return new RpcException(error_code.INVALID_REQUEST, 'A batch must ' + 'contain at least one request.').serialize(request); + } + + return Future.wait(request.map(_handleSingleRequest)).then((results) { + var nonNull = results.where((result) => result != null); + return nonNull.isEmpty ? null : nonNull.toList(); + }); + }); + } + + /// Parses and handles a JSON serialized request. + /// + /// This calls the appropriate method or methods for handling that request and + /// returns a JSON string, or `null` if no response should be sent. + Future parseRequest(String request) { + return syncFuture(() { + var decodedRequest; + try { + decodedRequest = JSON.decode(request); + } on FormatException catch (error) { + return new RpcException(error_code.PARSE_ERROR, 'Invalid JSON: ' + '${error.message}').serialize(request); + } + + return handleRequest(decodedRequest); + }).then((response) { + if (response == null) return null; + return JSON.encode(response); + }); + } + + /// Handles an individual parsed request. + Future _handleSingleRequest(request) { + return syncFuture(() { + _validateRequest(request); + + var name = request['method']; + var method = _methods[name]; + if (method == null) method = _tryFallbacks; + + if (method is ZeroArgumentFunction) { + if (!request.containsKey('params')) return method(); + throw new RpcException.invalidParams('No parameters are allowed for ' + 'method "$name".'); + } + + return method(new Parameters(name, request['params'])); + }).then((result) { + // A request without an id is a notification, which should not be sent a + // response, even if one is generated on the server. + if (!request.containsKey('id')) return null; + + return { + 'jsonrpc': '2.0', + 'result': result, + 'id': request['id'] + }; + }).catchError((error, stackTrace) { + if (error is! RpcException) { + error = new RpcException( + error_code.SERVER_ERROR, getErrorMessage(error), data: { + 'full': error.toString(), + 'stack': new Chain.forTrace(stackTrace).toString() + }); + } + + if (error.code != error_code.INVALID_REQUEST && + !request.containsKey('id')) { + return null; + } else { + return error.serialize(request); + } + }); + } + + /// Validates that [request] matches the JSON-RPC spec. + void _validateRequest(request) { + if (request is! Map) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request must be ' + 'an Array or an Object.'); + } + + if (!request.containsKey('jsonrpc')) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request must ' + 'contain a "jsonrpc" key.'); + } + + if (request['jsonrpc'] != '2.0') { + throw new RpcException(error_code.INVALID_REQUEST, 'Invalid JSON-RPC ' + 'version ${JSON.encode(request['jsonrpc'])}, expected "2.0".'); + } + + if (!request.containsKey('method')) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request must ' + 'contain a "method" key.'); + } + + var method = request['method']; + if (request['method'] is! String) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request method must ' + 'be a string, but was ${JSON.encode(method)}.'); + } + + var params = request['params']; + if (request.containsKey('params') && params is! List && params is! Map) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request params must ' + 'be an Array or an Object, but was ${JSON.encode(params)}.'); + } + + var id = request['id']; + if (id != null && id is! String && id is! num) { + throw new RpcException(error_code.INVALID_REQUEST, 'Request id must be a ' + 'string, number, or null, but was ${JSON.encode(id)}.'); + } + } + + /// Try all the fallback methods in order. + Future _tryFallbacks(Parameters params) { + var iterator = _fallbacks.toList().iterator; + + _tryNext() { + if (!iterator.moveNext()) { + return new Future.error( + new RpcException.methodNotFound(params.method), + new Chain.current()); + } + + return syncFuture(() => iterator.current(params)).catchError((error) { + if (error is! RpcException) throw error; + if (error.code != error_code.METHOD_NOT_FOUND) throw error; + return _tryNext(); + }); + } + + return _tryNext(); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..1eff004 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,45 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.utils; + +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; + +typedef ZeroArgumentFunction(); + +/// Like [new Future.sync], but automatically wraps the future in a +/// [Chain.track] call. +Future syncFuture(callback()) => Chain.track(new Future.sync(callback)); + +/// Returns a sentence fragment listing the elements of [iter]. +/// +/// This converts each element of [iter] to a string and separates them with +/// commas and/or "and" where appropriate. +String toSentence(Iterable iter) { + if (iter.length == 1) return iter.first.toString(); + return iter.take(iter.length - 1).join(", ") + " and ${iter.last}"; +} + +/// Returns [name] if [number] is 1, or the plural of [name] otherwise. +/// +/// By default, this just adds "s" to the end of [name] to get the plural. If +/// [plural] is passed, that's used instead. +String pluralize(String name, int number, {String plural}) { + if (number == 1) return name; + if (plural != null) return plural; + return '${name}s'; +} + +/// A regular expression to match the exception prefix that some exceptions' +/// [Object.toString] values contain. +final _exceptionPrefix = new RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): '); + +/// Get a string description of an exception. +/// +/// Many exceptions include the exception class name at the beginning of their +/// [toString], so we remove that if it exists. +String getErrorMessage(error) => + error.toString().replaceFirst(_exceptionPrefix, ''); diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..0919ac1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,13 @@ +name: json_rpc_2 +version: 0.0.1 +author: Dart Team +description: An implementation of the JSON-RPC 2.0 spec. +homepage: http://www.dartlang.org +documentation: http://api.dartlang.org/docs/pkg/json_rpc_2 +dependencies: + stack_trace: '>=0.9.1 <0.10.0' +dev_dependencies: + unittest: ">=0.9.0 <0.10.0" +environment: + sdk: ">=1.2.0 <2.0.0" + diff --git a/test/server/batch_test.dart b/test/server/batch_test.dart new file mode 100644 index 0000000..441df58 --- /dev/null +++ b/test/server/batch_test.dart @@ -0,0 +1,105 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.test.server.batch_test; + +import 'dart:convert'; + +import 'package:unittest/unittest.dart'; +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; + +import 'utils.dart'; + +void main() { + var server; + setUp(() { + server = new json_rpc.Server() + ..registerMethod('foo', () => 'foo') + ..registerMethod('id', (params) => params.value) + ..registerMethod('arg', (params) => params['arg'].value); + }); + + test('handles a batch of requests', () { + expect(server.handleRequest([ + {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'id', 'params': ['value'], 'id': 2}, + {'jsonrpc': '2.0', 'method': 'arg', 'params': {'arg': 'value'}, 'id': 3} + ]), completion(equals([ + {'jsonrpc': '2.0', 'result': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'result': ['value'], 'id': 2}, + {'jsonrpc': '2.0', 'result': 'value', 'id': 3} + ]))); + }); + + test('handles errors individually', () { + expect(server.handleRequest([ + {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'zap', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'arg', 'params': {'arg': 'value'}, 'id': 3} + ]), completion(equals([ + {'jsonrpc': '2.0', 'result': 'foo', 'id': 1}, + { + 'jsonrpc': '2.0', + 'id': 2, + 'error': { + 'code': error_code.METHOD_NOT_FOUND, + 'message': 'Unknown method "zap".', + 'data': {'request': {'jsonrpc': '2.0', 'method': 'zap', 'id': 2}}, + } + }, + {'jsonrpc': '2.0', 'result': 'value', 'id': 3} + ]))); + }); + + test('handles notifications individually', () { + expect(server.handleRequest([ + {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'id', 'params': ['value']}, + {'jsonrpc': '2.0', 'method': 'arg', 'params': {'arg': 'value'}, 'id': 3} + ]), completion(equals([ + {'jsonrpc': '2.0', 'result': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'result': 'value', 'id': 3} + ]))); + }); + + test('returns nothing if every request is a notification', () { + expect(server.handleRequest([ + {'jsonrpc': '2.0', 'method': 'foo'}, + {'jsonrpc': '2.0', 'method': 'id', 'params': ['value']}, + {'jsonrpc': '2.0', 'method': 'arg', 'params': {'arg': 'value'}} + ]), completion(isNull)); + }); + + test('returns an error if the batch is empty', () { + expectErrorResponse(server, [], error_code.INVALID_REQUEST, + 'A batch must contain at least one request.'); + }); + + test('handles a batch of requests parsed from JSON', () { + expect(server.parseRequest(JSON.encode([ + {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'id', 'params': ['value'], 'id': 2}, + {'jsonrpc': '2.0', 'method': 'arg', 'params': {'arg': 'value'}, 'id': 3} + ])), completion(equals(JSON.encode([ + {'jsonrpc': '2.0', 'result': 'foo', 'id': 1}, + {'jsonrpc': '2.0', 'result': ['value'], 'id': 2}, + {'jsonrpc': '2.0', 'result': 'value', 'id': 3} + ])))); + }); + + test('disallows nested batches', () { + expect(server.handleRequest([ + [{'jsonrpc': '2.0', 'method': 'foo', 'id': 1}] + ]), completion(equals([{ + 'jsonrpc': '2.0', + 'id': null, + 'error': { + 'code': error_code.INVALID_REQUEST, + 'message': 'Request must be an Array or an Object.', + 'data': {'request': [{'jsonrpc': '2.0', 'method': 'foo', 'id': 1}]} + } + }]))); + }); +} \ No newline at end of file diff --git a/test/server/invalid_request_test.dart b/test/server/invalid_request_test.dart new file mode 100644 index 0000000..feeefea --- /dev/null +++ b/test/server/invalid_request_test.dart @@ -0,0 +1,86 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.test.server.invalid_request_test; + +import 'dart:convert'; + +import 'package:unittest/unittest.dart'; +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; + +import 'utils.dart'; + +void main() { + var server; + setUp(() => server = new json_rpc.Server()); + + test("a non-Array/Object request is invalid", () { + expectErrorResponse(server, 'foo', error_code.INVALID_REQUEST, + 'Request must be an Array or an Object.'); + }); + + test("requests must have a jsonrpc key", () { + expectErrorResponse(server, { + 'method': 'foo', + 'id': 1234 + }, error_code.INVALID_REQUEST, 'Request must contain a "jsonrpc" key.'); + }); + + test("the jsonrpc version must be 2.0", () { + expectErrorResponse(server, { + 'jsonrpc': '1.0', + 'method': 'foo', + 'id': 1234 + }, error_code.INVALID_REQUEST, + 'Invalid JSON-RPC version "1.0", expected "2.0".'); + }); + + test("requests must have a method key", () { + expectErrorResponse(server, { + 'jsonrpc': '2.0', + 'id': 1234 + }, error_code.INVALID_REQUEST, 'Request must contain a "method" key.'); + }); + + test("request method must be a string", () { + expectErrorResponse(server, { + 'jsonrpc': '2.0', + 'method': 1234, + 'id': 1234 + }, error_code.INVALID_REQUEST, + 'Request method must be a string, but was 1234.'); + }); + + test("request params must be an Array or Object", () { + expectErrorResponse(server, { + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': 1234, + 'id': 1234 + }, error_code.INVALID_REQUEST, + 'Request params must be an Array or an Object, but was 1234.'); + }); + + test("request id may not be an Array or Object", () { + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'id': {'bad': 'id'} + }), completion(equals({ + 'jsonrpc': '2.0', + 'id': null, + 'error': { + 'code': error_code.INVALID_REQUEST, + 'message': 'Request id must be a string, number, or null, but was ' + '{"bad":"id"}.', + 'data': {'request': { + 'jsonrpc': '2.0', + 'method': 'foo', + 'id': {'bad': 'id'} + }} + } + }))); + }); +} diff --git a/test/server/parameters_test.dart b/test/server/parameters_test.dart new file mode 100644 index 0000000..9219475 --- /dev/null +++ b/test/server/parameters_test.dart @@ -0,0 +1,305 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.test.server.parameters_test; + +import 'package:unittest/unittest.dart'; +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; + +import 'utils.dart'; + +void main() { + group("with named parameters", () { + var parameters; + setUp(() { + parameters = new json_rpc.Parameters("foo", { + "num": 1.5, + "int": 1, + "bool": true, + "string": "zap", + "list": [1, 2, 3], + "map": { + "num": 4.2, + "bool": false + } + }); + }); + + test("value returns the wrapped value", () { + expect(parameters.value, equals({ + "num": 1.5, + "int": 1, + "bool": true, + "string": "zap", + "list": [1, 2, 3], + "map": { + "num": 4.2, + "bool": false + } + })); + }); + + test("[int] throws a parameter error", () { + expect(() => parameters[0], + throwsInvalidParams('Parameters for method "foo" must be passed by ' + 'position.')); + }); + + test("[].value returns existing parameters", () { + expect(parameters['num'].value, equals(1.5)); + }); + + test("[].valueOr returns existing parameters", () { + expect(parameters['num'].valueOr(7), equals(1.5)); + }); + + test("[].value fails for absent parameters", () { + expect(() => parameters['fblthp'].value, + throwsInvalidParams('Request for method "foo" is missing required ' + 'parameter "fblthp".')); + }); + + test("[].valueOr succeeds for absent parameters", () { + expect(parameters['fblthp'].valueOr(7), equals(7)); + }); + + test("[].exists returns true for existing parameters", () { + expect(parameters['num'].exists, isTrue); + }); + + test("[].exists returns false for missing parameters", () { + expect(parameters['fblthp'].exists, isFalse); + }); + + test("[].asNum returns numeric parameters", () { + expect(parameters['num'].asNum, equals(1.5)); + expect(parameters['int'].asNum, equals(1)); + }); + + test("[].asNumOr returns numeric parameters", () { + expect(parameters['num'].asNumOr(7), equals(1.5)); + }); + + test("[].asNum fails for non-numeric parameters", () { + expect(() => parameters['bool'].asNum, + throwsInvalidParams('Parameter "bool" for method "foo" must be a ' + 'number, but was true.')); + }); + + test("[].asNumOr fails for non-numeric parameters", () { + expect(() => parameters['bool'].asNumOr(7), + throwsInvalidParams('Parameter "bool" for method "foo" must be a ' + 'number, but was true.')); + }); + + test("[].asNum fails for absent parameters", () { + expect(() => parameters['fblthp'].asNum, + throwsInvalidParams('Request for method "foo" is missing required ' + 'parameter "fblthp".')); + }); + + test("[].asNumOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asNumOr(7), equals(7)); + }); + + test("[].asInt returns integer parameters", () { + expect(parameters['int'].asInt, equals(1)); + }); + + test("[].asIntOr returns integer parameters", () { + expect(parameters['int'].asIntOr(7), equals(1)); + }); + + test("[].asInt fails for non-integer parameters", () { + expect(() => parameters['bool'].asInt, + throwsInvalidParams('Parameter "bool" for method "foo" must be an ' + 'integer, but was true.')); + }); + + test("[].asIntOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asIntOr(7), equals(7)); + }); + + test("[].asBool returns boolean parameters", () { + expect(parameters['bool'].asBool, isTrue); + }); + + test("[].asBoolOr returns boolean parameters", () { + expect(parameters['bool'].asBoolOr(false), isTrue); + }); + + test("[].asBoolOr fails for non-boolean parameters", () { + expect(() => parameters['int'].asBool, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'boolean, but was 1.')); + }); + + test("[].asBoolOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asBoolOr(false), isFalse); + }); + + test("[].asString returns string parameters", () { + expect(parameters['string'].asString, equals("zap")); + }); + + test("[].asStringOr returns string parameters", () { + expect(parameters['string'].asStringOr("bap"), equals("zap")); + }); + + test("[].asString fails for non-string parameters", () { + expect(() => parameters['int'].asString, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'string, but was 1.')); + }); + + test("[].asStringOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asStringOr("bap"), equals("bap")); + }); + + test("[].asList returns list parameters", () { + expect(parameters['list'].asList, equals([1, 2, 3])); + }); + + test("[].asListOr returns list parameters", () { + expect(parameters['list'].asListOr([5, 6, 7]), equals([1, 2, 3])); + }); + + test("[].asList fails for non-list parameters", () { + expect(() => parameters['int'].asList, + throwsInvalidParams('Parameter "int" for method "foo" must be an ' + 'Array, but was 1.')); + }); + + test("[].asListOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asListOr([5, 6, 7]), equals([5, 6, 7])); + }); + + test("[].asMap returns map parameters", () { + expect(parameters['map'].asMap, equals({"num": 4.2, "bool": false})); + }); + + test("[].asMapOr returns map parameters", () { + expect(parameters['map'].asMapOr({}), + equals({"num": 4.2, "bool": false})); + }); + + test("[].asMap fails for non-map parameters", () { + expect(() => parameters['int'].asMap, + throwsInvalidParams('Parameter "int" for method "foo" must be an ' + 'Object, but was 1.')); + }); + + test("[].asMapOr succeeds for absent parameters", () { + expect(parameters['fblthp'].asMapOr({}), equals({})); + }); + + group("with a nested parameter map", () { + var nested; + setUp(() => nested = parameters['map']); + + test("[int] fails with a type error", () { + expect(() => nested[0], + throwsInvalidParams('Parameter "map" for method "foo" must be an ' + 'Array, but was {"num":4.2,"bool":false}.')); + }); + + test("[].value returns existing parameters", () { + expect(nested['num'].value, equals(4.2)); + expect(nested['bool'].value, isFalse); + }); + + test("[].value fails for absent parameters", () { + expect(() => nested['fblthp'].value, + throwsInvalidParams('Request for method "foo" is missing required ' + 'parameter map.fblthp.')); + }); + + test("typed getters return correctly-typed parameters", () { + expect(nested['num'].asNum, equals(4.2)); + }); + + test("typed getters fail for incorrectly-typed parameters", () { + expect(() => nested['bool'].asNum, + throwsInvalidParams('Parameter map.bool for method "foo" must be ' + 'a number, but was false.')); + }); + }); + + group("with a nested parameter list", () { + var nested; + setUp(() => nested = parameters['list']); + + test("[string] fails with a type error", () { + expect(() => nested['foo'], + throwsInvalidParams('Parameter "list" for method "foo" must be an ' + 'Object, but was [1,2,3].')); + }); + + test("[].value returns existing parameters", () { + expect(nested[0].value, equals(1)); + expect(nested[1].value, equals(2)); + }); + + test("[].value fails for absent parameters", () { + expect(() => nested[5].value, + throwsInvalidParams('Request for method "foo" is missing required ' + 'parameter list[5].')); + }); + + test("typed getters return correctly-typed parameters", () { + expect(nested[0].asInt, equals(1)); + }); + + test("typed getters fail for incorrectly-typed parameters", () { + expect(() => nested[0].asBool, + throwsInvalidParams('Parameter list[0] for method "foo" must be ' + 'a boolean, but was 1.')); + }); + }); + }); + + group("with positional parameters", () { + var parameters; + setUp(() => parameters = new json_rpc.Parameters("foo", [1, 2, 3, 4, 5])); + + test("value returns the wrapped value", () { + expect(parameters.value, equals([1, 2, 3, 4, 5])); + }); + + test("[string] throws a parameter error", () { + expect(() => parameters['foo'], + throwsInvalidParams('Parameters for method "foo" must be passed by ' + 'name.')); + }); + + test("[].value returns existing parameters", () { + expect(parameters[2].value, equals(3)); + }); + + test("[].value fails for out-of-range parameters", () { + expect(() => parameters[10].value, + throwsInvalidParams('Request for method "foo" is missing required ' + 'parameter 11.')); + }); + + test("[].exists returns true for existing parameters", () { + expect(parameters[0].exists, isTrue); + }); + + test("[].exists returns false for missing parameters", () { + expect(parameters[10].exists, isFalse); + }); + }); + + test("with a complex parameter path", () { + var parameters = new json_rpc.Parameters("foo", { + 'bar baz': [0, 1, 2, {'bang.zap': {'\n': 'qux'}}] + }); + + expect(() => parameters['bar baz'][3]['bang.zap']['\n']['bip'], + throwsInvalidParams('Parameter "bar baz"[3]."bang.zap"."\\n" for ' + 'method "foo" must be an Object, but was "qux".')); + }); +} diff --git a/test/server/server_test.dart b/test/server/server_test.dart new file mode 100644 index 0000000..f1e0af5 --- /dev/null +++ b/test/server/server_test.dart @@ -0,0 +1,203 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.test.server.server_test; + +import 'dart:convert'; + +import 'package:unittest/unittest.dart'; +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; + +import 'utils.dart'; + +void main() { + var server; + setUp(() => server = new json_rpc.Server()); + + test("calls a registered method with the given name", () { + server.registerMethod('foo', (params) { + return {'params': params.value}; + }); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'param': 'value'}, + 'id': 1234 + }), completion(equals({ + 'jsonrpc': '2.0', + 'result': {'params': {'param': 'value'}}, + 'id': 1234 + }))); + }); + + test("calls a method that takes no parameters", () { + server.registerMethod('foo', () => 'foo'); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'id': 1234 + }), completion(equals({ + 'jsonrpc': '2.0', + 'result': 'foo', + 'id': 1234 + }))); + }); + + test("a method that takes no parameters rejects parameters", () { + server.registerMethod('foo', () => 'foo'); + + expectErrorResponse(server, { + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {}, + 'id': 1234 + }, + error_code.INVALID_PARAMS, + 'No parameters are allowed for method "foo".'); + }); + + test("an unexpected error in a method is captured", () { + server.registerMethod('foo', () => throw new FormatException('bad format')); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'id': 1234 + }), completion({ + 'jsonrpc': '2.0', + 'id': 1234, + 'error': { + 'code': error_code.SERVER_ERROR, + 'message': 'bad format', + 'data': { + 'request': {'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}, + 'full': 'FormatException: bad format', + 'stack': contains('server_test.dart') + } + } + })); + }); + + test("doesn't return a result for a notification", () { + server.registerMethod('foo', (args) => 'result'); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {} + }), completion(isNull)); + }); + + group("JSON", () { + test("handles a request parsed from JSON", () { + server.registerMethod('foo', (params) { + return {'params': params.value}; + }); + + expect(server.parseRequest(JSON.encode({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'param': 'value'}, + 'id': 1234 + })), completion(equals(JSON.encode({ + 'jsonrpc': '2.0', + 'result': {'params': {'param': 'value'}}, + 'id': 1234 + })))); + }); + + test("handles a notification parsed from JSON", () { + server.registerMethod('foo', (params) { + return {'params': params}; + }); + + expect(server.parseRequest(JSON.encode({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'param': 'value'} + })), completion(isNull)); + }); + + test("a JSON parse error is rejected", () { + expect(server.parseRequest('invalid json {'), + completion(equals(JSON.encode({ + 'jsonrpc': '2.0', + 'error': { + 'code': error_code.PARSE_ERROR, + 'message': "Invalid JSON: Unexpected character at 0: 'invalid json " + "{'", + 'data': {'request': 'invalid json {'} + }, + 'id': null + })))); + }); + }); + + group("fallbacks", () { + test("calls a fallback if no method matches", () { + server.registerMethod('foo', () => 'foo'); + server.registerMethod('bar', () => 'foo'); + server.registerFallback((params) => {'fallback': params.value}); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'baz', + 'params': {'param': 'value'}, + 'id': 1234 + }), completion(equals({ + 'jsonrpc': '2.0', + 'result': {'fallback': {'param': 'value'}}, + 'id': 1234 + }))); + }); + + test("calls the first matching fallback", () { + server.registerFallback((params) => + throw new json_rpc.RpcException.methodNotFound(params.method)); + + server.registerFallback((params) => 'fallback 2'); + server.registerFallback((params) => 'fallback 3'); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'fallback 2', + 'id': 1234 + }), completion(equals({ + 'jsonrpc': '2.0', + 'result': 'fallback 2', + 'id': 1234 + }))); + }); + + test("an unexpected error in a fallback is captured", () { + server.registerFallback((_) => throw new FormatException('bad format')); + + expect(server.handleRequest({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'id': 1234 + }), completion({ + 'jsonrpc': '2.0', + 'id': 1234, + 'error': { + 'code': error_code.SERVER_ERROR, + 'message': 'bad format', + 'data': { + 'request': {'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}, + 'full': 'FormatException: bad format', + 'stack': contains('server_test.dart') + } + } + })); + }); + }); + + test("disallows multiple methods with the same name", () { + server.registerMethod('foo', () => null); + expect(() => server.registerMethod('foo', () => null), throwsArgumentError); + }); +} diff --git a/test/server/utils.dart b/test/server/utils.dart new file mode 100644 index 0000000..07f571c --- /dev/null +++ b/test/server/utils.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library json_rpc_2.test.server.util; + +import 'package:unittest/unittest.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; + +void expectErrorResponse(json_rpc.Server server, request, int errorCode, + String message) { + var id; + if (request is Map) id = request['id']; + + expect(server.handleRequest(request), completion(equals({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': errorCode, + 'message': message, + 'data': {'request': request} + } + }))); +} + +Matcher throwsInvalidParams(String message) { + return throwsA(predicate((error) { + expect(error, new isInstanceOf()); + expect(error.message, equals(message)); + return true; + })); +}