Create a package that implements a JSON-RPC 2.0 server.
R=rnystrom@google.com
BUG=17492
Review URL: https://codereview.chromium.org//205533005
git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/json_rpc_2@34223 260f80e4-7a28-3924-810f-c04153c831b5
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<String, Function>();
+
+ /// 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<Function>();
+
+ 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<String> 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 <misc@dartlang.org>
+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<json_rpc.RpcException>());
+ expect(error.message, equals(message));
+ return true;
+ }));
+}