diff --git a/.github/ISSUE_TEMPLATE/json_rpc_2.md b/.github/ISSUE_TEMPLATE/json_rpc_2.md new file mode 100644 index 000000000..29310dd09 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/json_rpc_2.md @@ -0,0 +1,5 @@ +--- +name: "package:json_rpc_2" +about: "Create a bug or file a feature request against package:json_rpc_2." +labels: "package:json_rpc_2" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index c3d5de0b9..bb75ff705 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -32,6 +32,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/graphs/**' +'package:json_rpc_2': + - changed-files: + - any-glob-to-any-file: 'pkgs/json_rpc_2/**' + 'package:mime': - changed-files: - any-glob-to-any-file: 'pkgs/mime/**' diff --git a/.github/workflows/json_rpc_2.yaml b/.github/workflows/json_rpc_2.yaml new file mode 100644 index 000000000..cd1ba682f --- /dev/null +++ b/.github/workflows/json_rpc_2.yaml @@ -0,0 +1,75 @@ +name: package:json_rpc_2 + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/json_rpc_2.yml' + - 'pkgs/json_rpc_2/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/json_rpc_2.yml' + - 'pkgs/json_rpc_2/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/json_rpc_2/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run browser tests + run: dart test --platform chrome --compiler dart2wasm,dart2js + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index ac4edf204..a32ee1646 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ don't naturally belong to other topic monorepos (like | [file](pkgs/file/) | A pluggable, mockable file system abstraction for Dart. | [![pub package](https://img.shields.io/pub/v/file.svg)](https://pub.dev/packages/file) | | [file_testing](pkgs/file_testing/) | Testing utilities for package:file (published but unlisted). | [![pub package](https://img.shields.io/pub/v/file_testing.svg)](https://pub.dev/packages/file_testing) | | [graphs](pkgs/graphs/) | Graph algorithms that operate on graphs in any representation | [![pub package](https://img.shields.io/pub/v/graphs.svg)](https://pub.dev/packages/graphs) | +| [json_rpc_2](pkgs/json_rpc_2/) | Utilities to write a client or server using the JSON-RPC 2.0 spec. | [![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) | | [mime](pkgs/mime/) | Utilities for handling media (MIME) types. | [![pub package](https://img.shields.io/pub/v/mime.svg)](https://pub.dev/packages/mime) | | [oauth2](pkgs/oauth2/) | A client library for authenticatingand making requests via OAuth2. | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) | | [source_map_stack_trace](pkgs/source_map_stack_trace/) | A package for applying source maps to stack traces. | [![pub package](https://img.shields.io/pub/v/source_map_stack_trace.svg)](https://pub.dev/packages/source_map_stack_trace) | diff --git a/pkgs/json_rpc_2/.gitignore b/pkgs/json_rpc_2/.gitignore new file mode 100644 index 000000000..ab3cb76e6 --- /dev/null +++ b/pkgs/json_rpc_2/.gitignore @@ -0,0 +1,16 @@ +# Don’t commit the following directories created by pub. +.buildlog +.dart_tool/ +.pub/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock diff --git a/pkgs/json_rpc_2/.test_config b/pkgs/json_rpc_2/.test_config new file mode 100644 index 000000000..412fc5c5c --- /dev/null +++ b/pkgs/json_rpc_2/.test_config @@ -0,0 +1,3 @@ +{ + "test_package": true +} \ No newline at end of file diff --git a/pkgs/json_rpc_2/CHANGELOG.md b/pkgs/json_rpc_2/CHANGELOG.md new file mode 100644 index 000000000..1f2cf8e8e --- /dev/null +++ b/pkgs/json_rpc_2/CHANGELOG.md @@ -0,0 +1,151 @@ +## 3.0.3 + +* Require Dart 3.4 +* Move to `dart-lang/tools` monorepo. + +## 3.0.2 + +* Switch to using `package:lints`. +* Address a few analysis hint violations. +* Populate the pubspec `repository` field. + +## 3.0.1 + +* Fix a bug where a `null` result to a request caused an exception. + +## 3.0.0 + +* Migrate to null safety. +* Accept responses even if the server converts the ID to a String. + +## 2.2.2 + +* Fix `Peer.close()` throwing `Bad state: Future already completed`. + +## 2.2.1 + +* Fix `Peer` requests not terminating when the underlying channel is closed. + +## 2.2.0 + +* Added `strictProtocolChecks` named parameter to `Server` and `Peer` + constructors. Setting this parameter to false will result in the server not + rejecting requests missing the `jsonrpc` parameter. + +## 2.1.1 + +* Fixed issue where throwing `RpcException.methodNotFound` in an asynchronous + fallback handler would not result in the next fallback being executed. +* Updated minimum SDK to Dart `2.2.0`. + +## 2.1.0 + +* `Server` and related classes can now take an `onUnhandledError` callback to + notify callers of unhandled exceptions. + +## 2.0.10 + +* Allow `stream_channel` version 2.x + +## 2.0.8 + +* Updated SDK version to 2.0.0-dev.17.0 + +## 2.0.7 + +* When a `Client` is closed before a request completes, the error sent to that + request's `Future` now includes the request method to aid in debugging. + +## 2.0.6 + +* Internal changes only. + +## 2.0.5 + +* Internal changes only. + +## 2.0.4 + +* `Client.sendRequest()` now throws a `StateError` if the client is closed while + the request is in-flight. This avoids dangling `Future`s that will never be + completed. + +* Both `Client.sendRequest()` and `Client.sendNotification()` now throw + `StateError`s if they're called after the client is closed. + +## 2.0.3 + +* Fix new strong-mode warnings. + +## 2.0.2 + +* Fix all strong-mode warnings. + +## 2.0.1 + +* Fix a race condition in which a `StateError` could be top-leveled if + `Peer.close()` was called before the underlying channel closed. + +## 2.0.0 + +* **Breaking change:** all constructors now take a `StreamChannel` rather than a + `Stream`/`StreamSink` pair. + +* `Client.sendRequest()` and `Client.sendNotification()` no longer throw + `StateError`s after the connection has been closed but before `Client.close()` + has been called. + +* The various `close()` methods may now be called before their corresponding + `listen()` methods. + +* The various `close()` methods now wait on the result of closing the underlying + `StreamSink`. Be aware that [in some circumstances][issue 19095] + `StreamController`s' `Sink.close()` futures may never complete. + +[issue 19095]: https://github.com/dart-lang/sdk/issues/19095 + +## 1.2.0 + +* Add `Client.isClosed` and `Server.isClosed`, which make it possible to + synchronously determine whether the connection is open. In particular, this + makes it possible to reliably tell whether it's safe to call + `Client.sendRequest`. + +* Fix a race condition in `Server` where a `StateError` could be thrown if the + connection was closed in the middle of handling a request. + +* Improve stack traces for error responses. + +## 1.1.1 + +* Update the README to match the current API. + +## 1.1.0 + +* Add a `done` getter to `Client`, `Server`, and `Peer`. + +## 1.0.0 + +* Add a `Client` class for communicating with external JSON-RPC 2.0 servers. + +* Add a `Peer` class that's both a `Client` and a `Server`. + +## 0.1.0 + +* Remove `Server.handleRequest()` and `Server.parseRequest()`. Instead, `new + Server()` takes a `Stream` and a `StreamSink` and uses those behind-the-scenes + for its communication. + +* Add `Server.listen()`, which causes the server to begin listening to the + underlying request stream. + +* Add `Server.close()`, which closes the underlying request stream and response + sink. + +## 0.0.2+3 + +* Widen the version constraint for `stack_trace`. + +## 0.0.2+2 + +* Fix error response to include data from `RpcException` when not a map. diff --git a/pkgs/json_rpc_2/LICENSE b/pkgs/json_rpc_2/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/json_rpc_2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +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 LLC 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/pkgs/json_rpc_2/README.md b/pkgs/json_rpc_2/README.md new file mode 100644 index 000000000..a7dda4a8e --- /dev/null +++ b/pkgs/json_rpc_2/README.md @@ -0,0 +1,150 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/json_rpc_2.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/json_rpc_2.yaml) +[![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) +[![package publisher](https://img.shields.io/pub/publisher/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2/publisher) + +A library that implements the [JSON-RPC 2.0 spec][spec]. + +[spec]: https://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 'dart:io'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() async { + var httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 4321); + var connectedChannels = + httpServer.transform(WebSocketTransformer()).map(IOWebSocketChannel.new); + connectedChannels.listen(handleClient); +} + +void handleClient(WebSocketChannel socket) { + // The socket is a `StreamChannel` because it might emit binary + // `List`, but JSON RPC 2 only works with Strings so we assert it only + // emits those by casting it. + var server = Server(socket.cast()); + + // 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', (Parameters 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['message'].value; + }); + + // `Parameters` has methods for verifying argument types. + server.registerMethod('subtract', (Parameters params) { + // If "minuend" or "subtrahend" aren't numbers, this will reject the + // request. + return params['minuend'].asNum - params['subtrahend'].asNum; + }); + + // [Parameters] also supports optional arguments. + server.registerMethod('sort', (Parameters params) { + var list = params['list'].asList; + list.sort(); + if (params['descendint'].asBoolOr(false)) { + return list.reversed; + } else { + return list; + } + }); + + // A method can send an error response by throwing a `RpcException`. + // Any positive number may be used as an application- defined error code. + const dividByZero = 1; + server.registerMethod('divide', (Parameters params) { + var divisor = params['divisor'].asNum; + if (divisor == 0) { + throw RpcException(dividByZero, 'Cannot divide by zero.'); + } + + return params['dividend'].asNum / divisor; + }); + + // To give you time to register all your methods, the server won't start + // listening for requests until you call `listen`. Messages are buffered until + // listen is called. The returned Future won't complete until the connection + // is closed. + server.listen(); +} +``` + +## Client + +A JSON-RPC 2.0 client calls methods on a server and handles the server's +responses to those method calls. These methods can be called using +`Client.sendRequest`: + +```dart +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() async { + var socket = WebSocketChannel.connect(Uri.parse('ws://localhost:4321')); + var client = Client(socket.cast()); + + // The client won't subscribe to the input stream until you call `listen`. + // The returned Future won't complete until the connection is closed. + unawaited(client.listen()); + + // This calls the "count" method on the server. A Future is returned that + // will complete to the value contained in the server's response. + var count = await client.sendRequest('count'); + print('Count is $count'); + + // Parameters are passed as a simple Map or, for positional parameters, an + // Iterable. Make sure they're JSON-serializable! + var echo = await client.sendRequest('echo', {'message': 'hello'}); + print('Echo says "$echo"!'); + + // A notification is a way to call a method that tells the server that no + // result is expected. Its return type is `void`; even if it causes an + // error, you won't hear back. + client.sendNotification('count'); + + // If the server sends an error response, the returned Future will complete + // with an RpcException. You can catch this error and inspect its error + // code, message, and any data that the server sent along with it. + try { + await client.sendRequest('divide', {'dividend': 2, 'divisor': 0}); + } on RpcException catch (error) { + print('RPC error ${error.code}: ${error.message}'); + } + + await client.close(); +} +``` + +## Peer + +Although JSON-RPC 2.0 only explicitly describes clients and servers, it also +mentions that two-way communication can be supported by making each endpoint +both a client and a server. This package supports this directly using the `Peer` +class, which implements both `Client` and `Server`. It supports the same methods +as those classes, and automatically makes sure that every message from the other +endpoint is routed and handled correctly. + +## Publishing automation + +For information about our publishing automation and release process, see +https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. diff --git a/pkgs/json_rpc_2/analysis_options.yaml b/pkgs/json_rpc_2/analysis_options.yaml new file mode 100644 index 000000000..a91d5cace --- /dev/null +++ b/pkgs/json_rpc_2/analysis_options.yaml @@ -0,0 +1,13 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: false + errors: + avoid_dynamic_calls: ignore + +linter: + rules: + - avoid_unused_constructor_parameters + - cancel_subscriptions + - package_api_docs diff --git a/pkgs/json_rpc_2/example/client.dart b/pkgs/json_rpc_2/example/client.dart new file mode 100644 index 000000000..aa8f7ed54 --- /dev/null +++ b/pkgs/json_rpc_2/example/client.dart @@ -0,0 +1,43 @@ +// Copyright (c) 2020, 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. + +import 'dart:async'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() async { + var socket = WebSocketChannel.connect(Uri.parse('ws://localhost:4321')); + var client = Client(socket.cast()); + + // The client won't subscribe to the input stream until you call `listen`. + // The returned Future won't complete until the connection is closed. + unawaited(client.listen()); + + // This calls the "count" method on the server. A Future is returned that + // will complete to the value contained in the server's response. + var count = await client.sendRequest('count'); + print('Count is $count'); + + // Parameters are passed as a simple Map or, for positional parameters, an + // Iterable. Make sure they're JSON-serializable! + var echo = await client.sendRequest('echo', {'message': 'hello'}); + print('Echo says "$echo"!'); + + // A notification is a way to call a method that tells the server that no + // result is expected. Its return type is `void`; even if it causes an + // error, you won't hear back. + client.sendNotification('count'); + + // If the server sends an error response, the returned Future will complete + // with an RpcException. You can catch this error and inspect its error + // code, message, and any data that the server sent along with it. + try { + await client.sendRequest('divide', {'dividend': 2, 'divisor': 0}); + } on RpcException catch (error) { + print('RPC error ${error.code}: ${error.message}'); + } + + await client.close(); +} diff --git a/pkgs/json_rpc_2/example/main.dart b/pkgs/json_rpc_2/example/main.dart new file mode 100644 index 000000000..7d5ab7331 --- /dev/null +++ b/pkgs/json_rpc_2/example/main.dart @@ -0,0 +1,78 @@ +// Copyright (c) 2020, 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. + +import 'dart:io'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() async { + var httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 4321); + var connectedChannels = + httpServer.transform(WebSocketTransformer()).map(IOWebSocketChannel.new); + connectedChannels.listen(handleClient); +} + +void handleClient(WebSocketChannel socket) { + // The socket is a `StreamChannel` because it might emit binary + // `List`, but JSON RPC 2 only works with Strings so we assert it only + // emits those by casting it. + var server = Server(socket.cast()); + + // 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', (Parameters 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['message'].value; + }); + + // `Parameters` has methods for verifying argument types. + server.registerMethod('subtract', (Parameters params) { + // If "minuend" or "subtrahend" aren't numbers, this will reject the + // request. + return params['minuend'].asNum - params['subtrahend'].asNum; + }); + + // [Parameters] also supports optional arguments. + server.registerMethod('sort', (Parameters params) { + var list = params['list'].asList; + list.sort(); + if (params['descendint'].asBoolOr(false)) { + return list.reversed; + } else { + return list; + } + }); + + // A method can send an error response by throwing a `RpcException`. + // Any positive number may be used as an application- defined error code. + const dividByZero = 1; + server.registerMethod('divide', (Parameters params) { + var divisor = params['divisor'].asNum; + if (divisor == 0) { + throw RpcException(dividByZero, 'Cannot divide by zero.'); + } + + return params['dividend'].asNum / divisor; + }); + + // To give you time to register all your methods, the server won't start + // listening for requests until you call `listen`. Messages are buffered until + // listen is called. The returned Future won't complete until the connection + // is closed. + server.listen(); +} diff --git a/pkgs/json_rpc_2/lib/error_code.dart b/pkgs/json_rpc_2/lib/error_code.dart new file mode 100644 index 000000000..5f907917d --- /dev/null +++ b/pkgs/json_rpc_2/lib/error_code.dart @@ -0,0 +1,51 @@ +// 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. + +// ignore_for_file: constant_identifier_names + +import 'src/exception.dart'; + +/// Error codes defined in the [JSON-RPC 2.0 specification][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 +/// 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 parameters 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; + +/// Returns a human-readable name for [errorCode] if it's one specified by the +/// JSON-RPC 2.0 spec. +/// +/// If [errorCode] isn't defined in the JSON-RPC 2.0 spec, returns `null`. +String? name(int errorCode) => switch (errorCode) { + PARSE_ERROR => 'parse error', + INVALID_REQUEST => 'invalid request', + METHOD_NOT_FOUND => 'method not found', + INVALID_PARAMS => 'invalid parameters', + INTERNAL_ERROR => 'internal error', + _ => null + }; diff --git a/pkgs/json_rpc_2/lib/json_rpc_2.dart b/pkgs/json_rpc_2/lib/json_rpc_2.dart new file mode 100644 index 000000000..33e5f4987 --- /dev/null +++ b/pkgs/json_rpc_2/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. + +export 'src/client.dart'; +export 'src/exception.dart'; +export 'src/parameters.dart'; +export 'src/peer.dart'; +export 'src/server.dart'; diff --git a/pkgs/json_rpc_2/lib/src/client.dart b/pkgs/json_rpc_2/lib/src/client.dart new file mode 100644 index 000000000..182f94584 --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/client.dart @@ -0,0 +1,246 @@ +// 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. + +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import 'exception.dart'; +import 'utils.dart'; + +/// A JSON-RPC 2.0 client. +/// +/// A client calls methods on a server and handles the server's responses to +/// those method calls. Methods can be called with [sendRequest], or with +/// [sendNotification] if no response is expected. +class Client { + final StreamChannel _channel; + + /// The next request id. + var _id = 0; + + /// The current batch of requests to be sent together. + /// + /// Each element is a JSON RPC spec compliant message. + List>? _batch; + + /// The map of request ids to pending requests. + final _pendingRequests = {}; + + final _done = Completer(); + + /// Returns a [Future] that completes when the underlying connection is + /// closed. + /// + /// This is the same future that's returned by [listen] and [close]. It may + /// complete before [close] is called if the remote endpoint closes the + /// connection. + Future get done => _done.future; + + /// Whether the underlying connection is closed. + /// + /// Note that this will be `true` before [close] is called if the remote + /// endpoint closes the connection. + bool get isClosed => _done.isCompleted; + + /// Creates a [Client] that communicates over [channel]. + /// + /// Note that the client won't begin listening to [channel] until + /// [Client.listen] is called. + Client(StreamChannel channel) + : this.withoutJson( + jsonDocument.bind(channel).transformStream(ignoreFormatExceptions)); + + /// Creates a [Client] that communicates using decoded messages over + /// [_channel]. + /// + /// Unlike [Client.new], this doesn't read or write JSON strings. Instead, it + /// reads and writes decoded maps or lists. + /// + /// Note that the client won't begin listening to [_channel] until + /// [Client.listen] is called. + Client.withoutJson(this._channel) { + done.whenComplete(() { + for (var request in _pendingRequests.values) { + request.completer.completeError(StateError( + 'The client closed with pending request "${request.method}".')); + } + _pendingRequests.clear(); + }).catchError((_) { + // Avoid an unhandled error. + }); + } + + /// Starts listening to the underlying stream. + /// + /// Returns a [Future] that will complete when the connection is closed or + /// when it has an error. This is the same as [done]. + /// + /// [listen] may only be called once. + Future listen() { + _channel.stream.listen(_handleResponse, + onError: (Object error, StackTrace stackTrace) { + _done.completeError(error, stackTrace); + _channel.sink.close(); + }, onDone: () { + if (!_done.isCompleted) _done.complete(); + close(); + }); + return done; + } + + /// Closes the underlying connection. + /// + /// Returns a [Future] that completes when all resources have been released. + /// This is the same as [done]. + Future close() { + _channel.sink.close(); + if (!_done.isCompleted) _done.complete(); + return done; + } + + /// Sends a JSON-RPC 2 request to invoke the given [method]. + /// + /// If passed, [parameters] is the parameters for the method. This must be + /// either an [Iterable] (to pass parameters by position) or a [Map] with + /// [String] keys (to pass parameters by name). Either way, it must be + /// JSON-serializable. + /// + /// If the request succeeds, this returns the response result as a decoded + /// JSON-serializable object. If it fails, it throws an [RpcException] + /// describing the failure. + /// + /// Throws a [StateError] if the client is closed while the request is in + /// flight, or if the client is closed when this method is called. + Future sendRequest(String method, [Object? parameters]) { + var id = _id++; + _send(method, parameters, id); + + var completer = Completer.sync(); + _pendingRequests[id] = _Request(method, completer, Chain.current()); + return completer.future; + } + + /// Sends a JSON-RPC 2 request to invoke the given [method] without expecting + /// a response. + /// + /// If passed, [parameters] is the parameters for the method. This must be + /// either an [Iterable] (to pass parameters by position) or a [Map] with + /// [String] keys (to pass parameters by name). Either way, it must be + /// JSON-serializable. + /// + /// Since this is just a notification to which the server isn't expected to + /// send a response, it has no return value. + /// + /// Throws a [StateError] if the client is closed when this method is called. + void sendNotification(String method, [Object? parameters]) => + _send(method, parameters); + + /// A helper method for [sendRequest] and [sendNotification]. + /// + /// Sends a request to invoke [method] with [parameters]. If [id] is given, + /// the request uses that id. + void _send(String method, Object? parameters, [int? id]) { + if (parameters is Iterable) parameters = parameters.toList(); + if (parameters is! Map && parameters is! List && parameters != null) { + throw ArgumentError('Only maps and lists may be used as JSON-RPC ' + 'parameters, was "$parameters".'); + } + if (isClosed) throw StateError('The client is closed.'); + + var message = {'jsonrpc': '2.0', 'method': method}; + if (id != null) message['id'] = id; + if (parameters != null) message['params'] = parameters; + + if (_batch != null) { + _batch!.add(message); + } else { + _channel.sink.add(message); + } + } + + /// Runs [callback] and batches any requests sent until it returns. + /// + /// A batch of requests is sent in a single message on the underlying stream, + /// and the responses are likewise sent back in a single message. + /// + /// [callback] may be synchronous or asynchronous. If it returns a [Future], + /// requests will be batched until that Future returns; otherwise, requests + /// will only be batched while synchronously executing [callback]. + /// + /// If this is called in the context of another [withBatch] call, it just + /// invokes [callback] without creating another batch. This means that + /// responses are batched until the first batch ends. + void withBatch(FutureOr Function() callback) { + if (_batch != null) { + callback(); + return; + } + + _batch = []; + return tryFinally(callback, () { + _channel.sink.add(_batch); + _batch = null; + }); + } + + /// Handles a decoded response from the server. + void _handleResponse(Object? response) { + if (response is List) { + response.forEach(_handleSingleResponse); + } else { + _handleSingleResponse(response); + } + } + + /// Handles a decoded response from the server after batches have been + /// resolved. + void _handleSingleResponse(Object? response_) { + if (!_isResponseValid(response_)) return; + final response = response_ as Map; + var id = response['id']; + id = (id is String) ? int.parse(id) : id; + var request = _pendingRequests.remove(id)!; + if (response.containsKey('result')) { + request.completer.complete(response['result']); + } else { + request.completer.completeError( + RpcException(response['error']['code'], response['error']['message'], + data: response['error']['data']), + request.chain); + } + } + + /// Determines whether the server's response is valid per the spec. + bool _isResponseValid(Object? response) { + if (response is! Map) return false; + if (response['jsonrpc'] != '2.0') return false; + var id = response['id']; + id = (id is String) ? int.parse(id) : id; + if (!_pendingRequests.containsKey(id)) return false; + if (response.containsKey('result')) return true; + + if (!response.containsKey('error')) return false; + var error = response['error']; + if (error is! Map) return false; + if (error['code'] is! int) return false; + if (error['message'] is! String) return false; + return true; + } +} + +/// A pending request to the server. +class _Request { + /// THe method that was sent. + final String method; + + /// The completer to use to complete the response future. + final Completer completer; + + /// The stack chain from where the request was made. + final Chain chain; + + _Request(this.method, this.completer, this.chain); +} diff --git a/pkgs/json_rpc_2/lib/src/exception.dart b/pkgs/json_rpc_2/lib/src/exception.dart new file mode 100644 index 000000000..906a0534b --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/exception.dart @@ -0,0 +1,75 @@ +// 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. + +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 Object? 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. + Map serialize(Object? request) { + dynamic modifiedData; + if (data is Map && !(data as Map).containsKey('request')) { + modifiedData = { + ...data as Map, + 'request': request, + }; + } else if (data == null) { + modifiedData = {'request': request}; + } else { + modifiedData = data; + } + + 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 + }; + } + + @override + String toString() { + var prefix = 'JSON-RPC error $code'; + var errorName = error_code.name(code); + if (errorName != null) prefix += ' ($errorName)'; + return '$prefix: $message'; + } +} diff --git a/pkgs/json_rpc_2/lib/src/parameters.dart b/pkgs/json_rpc_2/lib/src/parameters.dart new file mode 100644 index 000000000..0a188828c --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/parameters.dart @@ -0,0 +1,348 @@ +// 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. + +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 dynamic 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 [](Object? key) { + if (key is int) { + _assertPositional(); + if (key < value.length) { + return Parameter._(method, value[key], this, key); + } else { + return _MissingParameter(method, this, key); + } + } else if (key is String) { + _assertNamed(); + if (value.containsKey(key)) { + return Parameter._(method, value[key], this, key); + } else { + return _MissingParameter(method, this, key); + } + } else { + throw 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 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 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 Object _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() : jsonEncode(_key); + } + + String quoteKey(String key) { + if (key.contains(RegExp(r'[^a-zA-Z0-9_-]'))) return jsonEncode(key); + return key; + } + + String computePath(Parameter params) { + if (params._parent is! Parameter) { + return params._key is int + ? '[${params._key}]' + : quoteKey(params._key as String); + } + + var path = computePath(params._parent); + return params._key is int + ? '$path[${params._key}]' + : '$path.${quoteKey(params._key as String)}'; + } + + return computePath(this); + } + + /// Whether this parameter exists. + bool get exists => true; + + Parameter._(super.method, super.value, this._parent, this._key); + + /// Returns [value], or [defaultValue] if this parameter wasn't passed. + dynamic valueOr(Object? 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. + @override + 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. + /// + /// [asMapOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + @override + 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; + + /// Asserts that [value] exists, is a string, and can be parsed as a + /// [DateTime] and returns it. + /// + /// [asDateTimeOr] may be used to provide a default value instead of rejecting + /// the request if [value] doesn't exist. + DateTime get asDateTime => _getParsed('date/time', DateTime.parse); + + /// Asserts that [value] exists, is a string, and can be parsed as a + /// [DateTime] and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + DateTime asDateTimeOr(DateTime defaultValue) => asDateTime; + + /// Asserts that [value] exists, is a string, and can be parsed as a + /// [Uri] and returns it. + /// + /// [asUriOr] may be used to provide a default value instead of rejecting the + /// request if [value] doesn't exist. + Uri get asUri => _getParsed('URI', Uri.parse); + + /// Asserts that [value] exists, is a string, and can be parsed as a + /// [Uri] and returns it. + /// + /// If [value] doesn't exist, this returns [defaultValue]. + Uri asUriOr(Uri defaultValue) => asUri; + + /// Get a parameter named [type] that matches [test]. + /// + /// [type] is used for the error message. It should begin with an indefinite + /// article. + dynamic _getTyped(String type, bool Function(dynamic) test) { + if (test(value)) return value; + throw RpcException.invalidParams('Parameter $_path for method ' + '"$method" must be $type, but was ${jsonEncode(value)}.'); + } + + dynamic _getParsed(String description, void Function(String) parse) { + var string = asString; + try { + return parse(string); + } on FormatException catch (error) { + // DateTime.parse doesn't actually include any useful information in the + // FormatException, just the string that was being parsed. There's no use + // in including that in the RPC exception. See issue 17753. + var message = error.message; + if (message == string) { + message = ''; + } else { + message = '\n$message'; + } + + throw RpcException.invalidParams('Parameter $_path for method ' + '"$method" must be a valid $description, but was ' + '${jsonEncode(string)}.$message'); + } + } + + @override + void _assertPositional() { + // Throw the standard exception for a mis-typed list. + asList; + } + + @override + void _assertNamed() { + // Throw the standard exception for a mis-typed map. + asMap; + } +} + +/// A subclass of [Parameter] representing a missing parameter. +class _MissingParameter extends Parameter { + @override + dynamic get value { + throw RpcException.invalidParams('Request for method "$method" is ' + 'missing required parameter $_path.'); + } + + @override + bool get exists => false; + + _MissingParameter(String method, Parameters parent, Object key) + : super._(method, null, parent, key); + + @override + dynamic valueOr(Object? defaultValue) => defaultValue; + + @override + num asNumOr(num defaultValue) => defaultValue; + + @override + int asIntOr(int defaultValue) => defaultValue; + + @override + bool asBoolOr(bool defaultValue) => defaultValue; + + @override + String asStringOr(String defaultValue) => defaultValue; + + @override + List asListOr(List defaultValue) => defaultValue; + + @override + Map asMapOr(Map defaultValue) => defaultValue; + + @override + DateTime asDateTimeOr(DateTime defaultValue) => defaultValue; + + @override + Uri asUriOr(Uri defaultValue) => defaultValue; +} diff --git a/pkgs/json_rpc_2/lib/src/peer.dart b/pkgs/json_rpc_2/lib/src/peer.dart new file mode 100644 index 000000000..677b6e15f --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/peer.dart @@ -0,0 +1,156 @@ +// 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. + +import 'dart:async'; + +import 'package:stream_channel/stream_channel.dart'; + +import 'client.dart'; +import 'parameters.dart'; +import 'server.dart'; +import 'utils.dart'; + +/// A JSON-RPC 2.0 client *and* server. +/// +/// This supports bidirectional peer-to-peer communication with another JSON-RPC +/// 2.0 endpoint. It sends both requests and responses across the same +/// communication channel and expects to connect to a peer that does the same. +class Peer implements Client, Server { + final StreamChannel _channel; + + /// The underlying client that handles request-sending and response-receiving + /// logic. + late final Client _client; + + /// The underlying server that handles request-receiving and response-sending + /// logic. + late final Server _server; + + /// A stream controller that forwards incoming messages to [_server] if + /// they're requests. + final _serverIncomingForwarder = StreamController(sync: true); + + /// A stream controller that forwards incoming messages to [_client] if + /// they're responses. + final _clientIncomingForwarder = StreamController(sync: true); + + @override + late final Future done = Future.wait([_client.done, _server.done]); + + @override + bool get isClosed => _client.isClosed || _server.isClosed; + + @override + ErrorCallback? get onUnhandledError => _server.onUnhandledError; + + @override + bool get strictProtocolChecks => _server.strictProtocolChecks; + + /// Creates a [Peer] that communicates over [channel]. + /// + /// Note that the peer won't begin listening to [channel] until [Peer.listen] + /// is called. + /// + /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError]. + /// If this is not provided, unhandled exceptions will be swallowed. + /// + /// If [strictProtocolChecks] is false, the underlying [Server] will accept + /// some requests which are not conformant with the JSON-RPC 2.0 + /// specification. In particular, requests missing the `jsonrpc` parameter + /// will be accepted. + Peer(StreamChannel channel, + {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true}) + : this.withoutJson( + jsonDocument.bind(channel).transform(respondToFormatExceptions), + onUnhandledError: onUnhandledError, + strictProtocolChecks: strictProtocolChecks); + + /// Creates a [Peer] that communicates using decoded messages over [_channel]. + /// + /// Unlike [Peer.new], this doesn't read or write JSON strings. Instead, it + /// reads and writes decoded maps or lists. + /// + /// Note that the peer won't begin listening to [_channel] until + /// [Peer.listen] is called. + /// + /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError]. + /// If this is not provided, unhandled exceptions will be swallowed. + /// + /// If [strictProtocolChecks] is false, the underlying [Server] will accept + /// some requests which are not conformant with the JSON-RPC 2.0 + /// specification. In particular, requests missing the `jsonrpc` parameter + /// will be accepted. + Peer.withoutJson(this._channel, + {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true}) { + _server = Server.withoutJson( + StreamChannel(_serverIncomingForwarder.stream, _channel.sink), + onUnhandledError: onUnhandledError, + strictProtocolChecks: strictProtocolChecks); + _client = Client.withoutJson( + StreamChannel(_clientIncomingForwarder.stream, _channel.sink)); + } + + // Client methods. + + @override + Future sendRequest(String method, [Object? parameters]) => + _client.sendRequest(method, parameters); + + @override + void sendNotification(String method, [Object? parameters]) => + _client.sendNotification(method, parameters); + + @override + void withBatch(void Function() callback) => _client.withBatch(callback); + + // Server methods. + + @override + void registerMethod(String name, Function callback) => + _server.registerMethod(name, callback); + + @override + void registerFallback(void Function(Parameters parameters) callback) => + _server.registerFallback(callback); + + // Shared methods. + + @override + Future listen() { + _client.listen(); + _server.listen(); + _channel.stream.listen((message) { + if (message is Map) { + if (message.containsKey('result') || message.containsKey('error')) { + _clientIncomingForwarder.add(message); + } else { + _serverIncomingForwarder.add(message); + } + } else if (message is List && + message.isNotEmpty && + message.first is Map) { + if (message.first.containsKey('result') || + message.first.containsKey('error')) { + _clientIncomingForwarder.add(message); + } else { + _serverIncomingForwarder.add(message); + } + } else { + // Non-Map and -List messages are ill-formed, so we pass them to the + // server since it knows how to send error responses. + _serverIncomingForwarder.add(message); + } + }, onError: (Object error, StackTrace stackTrace) { + _serverIncomingForwarder.addError(error, stackTrace); + }, onDone: close); + return done; + } + + @override + Future close() { + _client.close(); + _server.close(); + return done; + } +} diff --git a/pkgs/json_rpc_2/lib/src/server.dart b/pkgs/json_rpc_2/lib/src/server.dart new file mode 100644 index 000000000..2c58b7943 --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/server.dart @@ -0,0 +1,319 @@ +// 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. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import '../error_code.dart' as error_code; +import 'exception.dart'; +import 'parameters.dart'; +import 'utils.dart'; + +/// A callback for unhandled exceptions. +typedef ErrorCallback = void Function(dynamic error, dynamic stackTrace); + +/// 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]. +/// +/// 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 { + final StreamChannel _channel; + + /// The methods registered for this server. + final _methods = {}; + + /// The fallback methods for this server. + /// + /// These are tried in order until one of them doesn't throw a + /// [RpcException.methodNotFound] exception. + final _fallbacks = Queue(); + + final _done = Completer(); + + /// Returns a [Future] that completes when the underlying connection is + /// closed. + /// + /// This is the same future that's returned by [listen] and [close]. It may + /// complete before [close] is called if the remote endpoint closes the + /// connection. + Future get done => _done.future; + + /// Whether the underlying connection is closed. + /// + /// Note that this will be `true` before [close] is called if the remote + /// endpoint closes the connection. + bool get isClosed => _done.isCompleted; + + /// A callback that is fired on unhandled exceptions. + /// + /// In the case where a user provided callback results in an exception that + /// cannot be properly routed back to the client, this handler will be + /// invoked. If it is not set, the exception will be swallowed. + final ErrorCallback? onUnhandledError; + + /// Whether to strictly enforce the JSON-RPC 2.0 specification for received + /// messages. + /// + /// If `false`, this [Server] will accept some requests which are not + /// conformant with the JSON-RPC 2.0 specification. In particular, requests + /// missing the `jsonrpc` parameter will be accepted. + final bool strictProtocolChecks; + + /// Creates a [Server] that communicates over [channel]. + /// + /// Note that the server won't begin listening to [channel] until + /// [Server.listen] is called. + /// + /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError]. + /// If this is not provided, unhandled exceptions will be swallowed. + /// + /// If [strictProtocolChecks] is false, this [Server] will accept some + /// requests which are not conformant with the JSON-RPC 2.0 specification. In + /// particular, requests missing the `jsonrpc` parameter will be accepted. + Server(StreamChannel channel, + {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true}) + : this.withoutJson( + jsonDocument.bind(channel).transform(respondToFormatExceptions), + onUnhandledError: onUnhandledError, + strictProtocolChecks: strictProtocolChecks); + + /// Creates a [Server] that communicates using decoded messages over + /// [_channel]. + /// + /// Unlike [Server.new], this doesn't read or write JSON strings. Instead, it + /// reads and writes decoded maps or lists. + /// + /// Note that the server won't begin listening to [_channel] until + /// [Server.listen] is called. + /// + /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError]. + /// If this is not provided, unhandled exceptions will be swallowed. + /// + /// If [strictProtocolChecks] is false, this [Server] will accept some + /// requests which are not conformant with the JSON-RPC 2.0 specification. In + /// particular, requests missing the `jsonrpc` parameter will be accepted. + Server.withoutJson(this._channel, + {this.onUnhandledError, this.strictProtocolChecks = true}); + + /// Starts listening to the underlying stream. + /// + /// Returns a [Future] that will complete when the connection is closed or + /// when it has an error. This is the same as [done]. + /// + /// [listen] may only be called once. + Future listen() { + _channel.stream.listen(_handleRequest, + onError: (Object error, StackTrace stackTrace) { + _done.completeError(error, stackTrace); + _channel.sink.close(); + }, onDone: () { + if (!_done.isCompleted) _done.complete(); + }); + return done; + } + + /// Closes the underlying connection. + /// + /// Returns a [Future] that completes when all resources have been released. + /// This is the same as [done]. + Future close() { + _channel.sink.close(); + if (!_done.isCompleted) _done.complete(); + return done; + } + + /// 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 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(void Function(Parameters parameters) callback) { + _fallbacks.add(callback); + } + + /// Handle a request. + /// + /// [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. + Future _handleRequest(Object? request) async { + dynamic response; + if (request is List) { + if (request.isEmpty) { + response = RpcException(error_code.INVALID_REQUEST, + 'A batch must contain at least one request.') + .serialize(request); + } else { + var results = await Future.wait(request.map(_handleSingleRequest)); + var nonNull = results.where((result) => result != null); + if (nonNull.isEmpty) return; + response = nonNull.toList(); + } + } else { + response = await _handleSingleRequest(request); + if (response == null) return; + } + + if (!isClosed) _channel.sink.add(response); + } + + /// Handles an individual parsed request. + Future _handleSingleRequest(Object? request) async { + try { + _validateRequest(request); + request = request as Map; + + var name = request['method']; + var method = _methods[name]; + method ??= _tryFallbacks; + + Object? result; + if (method is ZeroArgumentFunction) { + if (request.containsKey('params')) { + throw RpcException.invalidParams('No parameters are allowed for ' + 'method "$name".'); + } + result = await method(); + } else { + result = await method(Parameters(name, request['params'])); + } + + // 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']}; + } catch (error, stackTrace) { + if (error is RpcException) { + if (error.code == error_code.INVALID_REQUEST || + (request is Map && request.containsKey('id'))) { + return error.serialize(request); + } else { + onUnhandledError?.call(error, stackTrace); + return null; + } + } else if (request is Map && !request.containsKey('id')) { + onUnhandledError?.call(error, stackTrace); + return null; + } + final chain = Chain.forTrace(stackTrace); + return RpcException(error_code.SERVER_ERROR, getErrorMessage(error), + data: { + 'full': '$error', + 'stack': '$chain', + }).serialize(request); + } + } + + /// Validates that [request] matches the JSON-RPC spec. + void _validateRequest(Object? request) { + if (request is! Map) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request must be ' + 'an Array or an Object.'); + } + + if (strictProtocolChecks && !request.containsKey('jsonrpc')) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request must ' + 'contain a "jsonrpc" key.'); + } + + if ((strictProtocolChecks || request.containsKey('jsonrpc')) && + request['jsonrpc'] != '2.0') { + throw RpcException( + error_code.INVALID_REQUEST, + 'Invalid JSON-RPC ' + 'version ${jsonEncode(request['jsonrpc'])}, expected "2.0".'); + } + + if (!request.containsKey('method')) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request must ' + 'contain a "method" key.'); + } + + var method = request['method']; + if (request['method'] is! String) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request method must ' + 'be a string, but was ${jsonEncode(method)}.'); + } + + if (request.containsKey('params')) { + var params = request['params']; + if (params is! List && params is! Map) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request params must ' + 'be an Array or an Object, but was ${jsonEncode(params)}.'); + } + } + + var id = request['id']; + if (id != null && id is! String && id is! num) { + throw RpcException( + error_code.INVALID_REQUEST, + 'Request id must be a ' + 'string, number, or null, but was ${jsonEncode(id)}.'); + } + } + + /// Try all the fallback methods in order. + Future _tryFallbacks(Parameters params) { + var iterator = _fallbacks.toList().iterator; + + Future tryNext() async { + if (!iterator.moveNext()) { + throw RpcException.methodNotFound(params.method); + } + + try { + return await iterator.current(params); + } on RpcException catch (error) { + if (error.code != error_code.METHOD_NOT_FOUND) rethrow; + return tryNext(); + } + } + + return tryNext(); + } +} diff --git a/pkgs/json_rpc_2/lib/src/utils.dart b/pkgs/json_rpc_2/lib/src/utils.dart new file mode 100644 index 000000000..28bbf21ee --- /dev/null +++ b/pkgs/json_rpc_2/lib/src/utils.dart @@ -0,0 +1,70 @@ +// 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. + +import 'dart:async'; + +import 'package:stream_channel/stream_channel.dart'; + +import '../error_code.dart' as error_code; +import 'exception.dart'; + +typedef ZeroArgumentFunction = FutureOr Function(); + +/// A regular expression to match the exception prefix that some exceptions' +/// [Object.toString] values contain. +final _exceptionPrefix = 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(Object error) => + error.toString().replaceFirst(_exceptionPrefix, ''); + +/// Like `try`/`finally`, run [body] and ensure that [whenComplete] runs +/// afterwards, regardless of whether [body] succeeded. +/// +/// This is synchronicity-agnostic relative to [body]. If [body] returns a +/// [Future], this wil run asynchronously; otherwise it will run synchronously. +void tryFinally(dynamic Function() body, void Function() whenComplete) { + dynamic result; + try { + result = body(); + } catch (_) { + whenComplete(); + rethrow; + } + + if (result is! Future) { + whenComplete(); + } else { + result.whenComplete(whenComplete); + } +} + +/// A transformer that silently drops [FormatException]s. +final ignoreFormatExceptions = StreamTransformer.fromHandlers( + handleError: (error, stackTrace, sink) { + if (error is FormatException) return; + sink.addError(error, stackTrace); +}); + +/// A transformer that sends error responses on [FormatException]s. +final StreamChannelTransformer respondToFormatExceptions = + _RespondToFormatExceptionsTransformer(); + +class _RespondToFormatExceptionsTransformer + implements StreamChannelTransformer { + @override + StreamChannel bind(StreamChannel channel) { + return channel.changeStream((stream) { + return stream.handleError((dynamic error) { + final formatException = error as FormatException; + var exception = RpcException( + error_code.PARSE_ERROR, 'Invalid JSON: ${formatException.message}'); + channel.sink.add(exception.serialize(formatException.source)); + }, test: (error) => error is FormatException); + }); + } +} diff --git a/pkgs/json_rpc_2/pubspec.yaml b/pkgs/json_rpc_2/pubspec.yaml new file mode 100644 index 000000000..6f5313176 --- /dev/null +++ b/pkgs/json_rpc_2/pubspec.yaml @@ -0,0 +1,17 @@ +name: json_rpc_2 +version: 3.0.3 +description: >- + Utilities to write a client or server using the JSON-RPC 2.0 spec. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/json_rpc_2 + +environment: + sdk: ^3.4.0 + +dependencies: + stack_trace: ^1.10.0 + stream_channel: ^2.1.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.25.5 + web_socket_channel: ^3.0.0 diff --git a/pkgs/json_rpc_2/test/client/client_test.dart b/pkgs/json_rpc_2/test/client/client_test.dart new file mode 100644 index 000000000..1a4f65d08 --- /dev/null +++ b/pkgs/json_rpc_2/test/client/client_test.dart @@ -0,0 +1,218 @@ +// 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. + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late ClientController controller; + + setUp(() => controller = ClientController()); + + test('sends a message and returns the response', () { + controller.expectRequest((request) { + expect( + request, + allOf([ + containsPair('jsonrpc', '2.0'), + containsPair('method', 'foo'), + containsPair('params', {'param': 'value'}) + ])); + + return {'jsonrpc': '2.0', 'result': 'bar', 'id': request['id']}; + }); + + expect(controller.client.sendRequest('foo', {'param': 'value'}), + completion(equals('bar'))); + }); + + test('sends a message and returns the response with String id', () { + controller.expectRequest((request) { + expect( + request, + allOf([ + containsPair('jsonrpc', '2.0'), + containsPair('method', 'foo'), + containsPair('params', {'param': 'value'}) + ])); + + return { + 'jsonrpc': '2.0', + 'result': 'bar', + 'id': request['id'].toString() + }; + }); + + expect(controller.client.sendRequest('foo', {'param': 'value'}), + completion(equals('bar'))); + }); + + test('sends a notification and expects no response', () { + controller.expectRequest((request) { + expect( + request, + equals({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'param': 'value'} + })); + }); + + controller.client.sendNotification('foo', {'param': 'value'}); + }); + + test('sends a notification with positional parameters', () { + controller.expectRequest((request) { + expect( + request, + equals({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': ['value1', 'value2'] + })); + }); + + controller.client.sendNotification('foo', ['value1', 'value2']); + }); + + test('sends a notification with no parameters', () { + controller.expectRequest((request) { + expect(request, equals({'jsonrpc': '2.0', 'method': 'foo'})); + }); + + controller.client.sendNotification('foo'); + }); + + test('sends a synchronous batch of requests', () { + controller.expectRequest((request) { + expect(request, isA()); + expect(request, hasLength(3)); + expect(request[0], equals({'jsonrpc': '2.0', 'method': 'foo'})); + expect( + request[1], + allOf([ + containsPair('jsonrpc', '2.0'), + containsPair('method', 'bar'), + containsPair('params', {'param': 'value'}) + ])); + expect( + request[2], + allOf( + [containsPair('jsonrpc', '2.0'), containsPair('method', 'baz')])); + + return [ + {'jsonrpc': '2.0', 'result': 'baz response', 'id': request[2]['id']}, + {'jsonrpc': '2.0', 'result': 'bar response', 'id': request[1]['id']} + ]; + }); + + controller.client.withBatch(() { + controller.client.sendNotification('foo'); + expect(controller.client.sendRequest('bar', {'param': 'value'}), + completion(equals('bar response'))); + expect(controller.client.sendRequest('baz'), + completion(equals('baz response'))); + }); + }); + + test('sends an asynchronous batch of requests', () { + controller.expectRequest((request) { + expect(request, isA()); + expect(request, hasLength(3)); + expect(request[0], equals({'jsonrpc': '2.0', 'method': 'foo'})); + expect( + request[1], + allOf([ + containsPair('jsonrpc', '2.0'), + containsPair('method', 'bar'), + containsPair('params', {'param': 'value'}) + ])); + expect( + request[2], + allOf( + [containsPair('jsonrpc', '2.0'), containsPair('method', 'baz')])); + + return [ + {'jsonrpc': '2.0', 'result': 'baz response', 'id': request[2]['id']}, + {'jsonrpc': '2.0', 'result': 'bar response', 'id': request[1]['id']} + ]; + }); + + controller.client.withBatch(() { + return Future.value().then((_) { + controller.client.sendNotification('foo'); + }).then((_) { + expect(controller.client.sendRequest('bar', {'param': 'value'}), + completion(equals('bar response'))); + }).then((_) { + expect(controller.client.sendRequest('baz'), + completion(equals('baz response'))); + }); + }); + }); + + test('reports an error from the server', () { + controller.expectRequest((request) { + expect( + request, + allOf( + [containsPair('jsonrpc', '2.0'), containsPair('method', 'foo')])); + + return { + 'jsonrpc': '2.0', + 'error': { + 'code': error_code.SERVER_ERROR, + 'message': 'you are bad at requests', + 'data': 'some junk' + }, + 'id': request['id'] + }; + }); + + expect( + controller.client.sendRequest('foo', {'param': 'value'}), + throwsA(isA() + .having((e) => e.code, 'code', error_code.SERVER_ERROR) + .having((e) => e.message, 'message', 'you are bad at requests') + .having((e) => e.data, 'data', 'some junk'))); + }); + + test('requests throw StateErrors if the client is closed', () { + controller.client.close(); + expect(() => controller.client.sendRequest('foo'), throwsStateError); + expect(() => controller.client.sendNotification('foo'), throwsStateError); + }); + + test('ignores bogus responses', () { + // Make a request so we have something to respond to. + controller.expectRequest((request) { + controller.sendJsonResponse('{invalid'); + controller.sendResponse('not a map'); + controller.sendResponse( + {'jsonrpc': 'wrong version', 'result': 'wrong', 'id': request['id']}); + controller.sendResponse({'jsonrpc': '2.0', 'result': 'wrong'}); + controller.sendResponse({'jsonrpc': '2.0', 'id': request['id']}); + controller.sendResponse( + {'jsonrpc': '2.0', 'error': 'not a map', 'id': request['id']}); + controller.sendResponse({ + 'jsonrpc': '2.0', + 'error': {'code': 'not an int', 'message': 'dang yo'}, + 'id': request['id'] + }); + controller.sendResponse({ + 'jsonrpc': '2.0', + 'error': {'code': 123, 'message': 0xDEADBEEF}, + 'id': request['id'] + }); + + return pumpEventQueue().then( + (_) => {'jsonrpc': '2.0', 'result': 'right', 'id': request['id']}); + }); + + expect(controller.client.sendRequest('foo'), completion(equals('right'))); + }); +} diff --git a/pkgs/json_rpc_2/test/client/stream_test.dart b/pkgs/json_rpc_2/test/client/stream_test.dart new file mode 100644 index 000000000..b33778ed3 --- /dev/null +++ b/pkgs/json_rpc_2/test/client/stream_test.dart @@ -0,0 +1,97 @@ +// 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. + +import 'dart:async'; + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +void main() { + late StreamController responseController; + late StreamController requestController; + late json_rpc.Client client; + + setUp(() { + responseController = StreamController(); + requestController = StreamController(); + client = json_rpc.Client.withoutJson( + StreamChannel(responseController.stream, requestController.sink)); + }); + + test('.withoutJson supports decoded stream and sink', () { + client.listen(); + + expect(requestController.stream.first.then((request) { + expect( + request, + allOf( + [containsPair('jsonrpc', '2.0'), containsPair('method', 'foo')])); + + responseController + .add({'jsonrpc': '2.0', 'result': 'bar', 'id': request['id']}); + }), completes); + + client.sendRequest('foo'); + }); + + test('.listen returns when the controller is closed', () { + var hasListenCompeted = false; + expect(client.listen().then((_) => hasListenCompeted = true), completes); + + return pumpEventQueue().then((_) { + expect(hasListenCompeted, isFalse); + + // This should cause listen to complete. + return responseController.close(); + }); + }); + + test('.listen returns a stream error', () { + expect(client.listen(), throwsA('oh no')); + responseController.addError('oh no'); + }); + + test('.listen can\'t be called twice', () { + client.listen(); + expect(() => client.listen(), throwsStateError); + }); + + test('.close cancels the stream subscription and closes the sink', () { + // Work around sdk#19095. + requestController.stream.listen(null); + + expect(client.listen(), completes); + + expect(client.isClosed, isFalse); + expect(client.close(), completes); + expect(client.isClosed, isTrue); + + expect(() => responseController.stream.listen((_) {}), throwsStateError); + expect(requestController.isClosed, isTrue); + }); + + group('a stream error', () { + test('is reported through .done', () { + expect(client.listen(), throwsA('oh no!')); + expect(client.done, throwsA('oh no!')); + responseController.addError('oh no!'); + }); + + test('cause a pending request to throw a StateError', () { + expect(client.listen(), throwsA('oh no!')); + expect(client.sendRequest('foo'), throwsStateError); + responseController.addError('oh no!'); + }); + + test('causes future requests to throw StateErrors', () async { + expect(client.listen(), throwsA('oh no!')); + responseController.addError('oh no!'); + await pumpEventQueue(); + + expect(() => client.sendRequest('foo'), throwsStateError); + expect(() => client.sendNotification('foo'), throwsStateError); + }); + }); +} diff --git a/pkgs/json_rpc_2/test/client/utils.dart b/pkgs/json_rpc_2/test/client/utils.dart new file mode 100644 index 000000000..38e187f28 --- /dev/null +++ b/pkgs/json_rpc_2/test/client/utils.dart @@ -0,0 +1,56 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// A controller used to test a [json_rpc.Client]. +class ClientController { + /// The controller for the client's response stream. + final _responseController = StreamController(); + + /// The controller for the client's request sink. + final _requestController = StreamController(); + + /// The client. + late final json_rpc.Client client; + + ClientController() { + client = json_rpc.Client( + StreamChannel(_responseController.stream, _requestController.sink)); + client.listen(); + } + + /// Expects that the client will send a request. + /// + /// The request is passed to [callback], which can return a response. If it + /// returns a String, that's sent as the response directly. If it returns + /// null, no response is sent. Otherwise, the return value is encoded and sent + /// as the response. + void expectRequest(FutureOr Function(dynamic) callback) { + expect( + _requestController.stream.first.then((request) { + return callback(jsonDecode(request)); + }).then((response) { + if (response == null) return; + if (response is! String) response = jsonEncode(response); + _responseController.add(response); + }), + completes); + } + + /// Sends [response], a decoded response, to [client]. + void sendResponse(Object? response) { + sendJsonResponse(jsonEncode(response)); + } + + /// Sends [request], a JSON-encoded response, to [client]. + void sendJsonResponse(String request) { + _responseController.add(request); + } +} diff --git a/pkgs/json_rpc_2/test/peer_test.dart b/pkgs/json_rpc_2/test/peer_test.dart new file mode 100644 index 000000000..0df605619 --- /dev/null +++ b/pkgs/json_rpc_2/test/peer_test.dart @@ -0,0 +1,251 @@ +// 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. + +// ignore_for_file: inference_failure_on_instance_creation + +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +void main() { + late StreamSink incoming; + late Stream outgoing; + late json_rpc.Peer peer; + + setUp(() { + var incomingController = StreamController(); + incoming = incomingController.sink; + var outgoingController = StreamController(); + outgoing = outgoingController.stream; + peer = json_rpc.Peer.withoutJson( + StreamChannel(incomingController.stream, outgoingController)); + }); + + group('like a client,', () { + test('can send a message and receive a response', () { + expect(outgoing.first.then((request) { + expect( + request, + equals({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'bar': 'baz'}, + 'id': 0 + })); + incoming.add({'jsonrpc': '2.0', 'result': 'qux', 'id': 0}); + }), completes); + + peer.listen(); + expect( + peer.sendRequest('foo', {'bar': 'baz'}), completion(equals('qux'))); + }); + + test('can send a batch of messages and receive a batch of responses', () { + expect(outgoing.first.then((request) { + expect( + request, + equals([ + { + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'bar': 'baz'}, + 'id': 0 + }, + { + 'jsonrpc': '2.0', + 'method': 'a', + 'params': {'b': 'c'}, + 'id': 1 + }, + { + 'jsonrpc': '2.0', + 'method': 'w', + 'params': {'x': 'y'}, + 'id': 2 + } + ])); + + incoming.add([ + {'jsonrpc': '2.0', 'result': 'qux', 'id': 0}, + {'jsonrpc': '2.0', 'result': 'd', 'id': 1}, + {'jsonrpc': '2.0', 'result': 'z', 'id': 2} + ]); + }), completes); + + peer.listen(); + + peer.withBatch(() { + expect( + peer.sendRequest('foo', {'bar': 'baz'}), completion(equals('qux'))); + expect(peer.sendRequest('a', {'b': 'c'}), completion(equals('d'))); + expect(peer.sendRequest('w', {'x': 'y'}), completion(equals('z'))); + }); + }); + + test('requests terminates when the channel is closed', () async { + var incomingController = StreamController(); + var channel = StreamChannel.withGuarantees( + incomingController.stream, + StreamController(), + ); + var peer = json_rpc.Peer.withoutJson(channel); + unawaited(peer.listen()); + + var response = peer.sendRequest('foo'); + await incomingController.close(); + + expect(response, throwsStateError); + }); + }); + + test('can be closed', () async { + var incomingController = StreamController(); + var channel = StreamChannel.withGuarantees( + incomingController.stream, + StreamController(), + ); + var peer = json_rpc.Peer.withoutJson(channel); + unawaited(peer.listen()); + await peer.close(); + }); + + test('considered closed with misbehaving StreamChannel', () async { + // If a StreamChannel does not enforce the guarantees stated in it's + // contract - specifically that "Closing the sink causes the stream to close + // before it emits any more events." - The `Peer` should still understand + // when it has been closed manually. + var channel = StreamChannel( + StreamController().stream, + StreamController(), + ); + var peer = json_rpc.Peer.withoutJson(channel); + unawaited(peer.listen()); + unawaited(peer.close()); + expect(peer.isClosed, true); + }); + + group('like a server,', () { + test('can receive a call and return a response', () { + expect(outgoing.first, + completion(equals({'jsonrpc': '2.0', 'result': 'qux', 'id': 0}))); + + peer.registerMethod('foo', (_) => 'qux'); + peer.listen(); + + incoming.add({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'bar': 'baz'}, + 'id': 0 + }); + }); + + test('can receive a batch of calls and return a batch of responses', () { + expect( + outgoing.first, + completion(equals([ + {'jsonrpc': '2.0', 'result': 'qux', 'id': 0}, + {'jsonrpc': '2.0', 'result': 'd', 'id': 1}, + {'jsonrpc': '2.0', 'result': 'z', 'id': 2} + ]))); + + peer.registerMethod('foo', (_) => 'qux'); + peer.registerMethod('a', (_) => 'd'); + peer.registerMethod('w', (_) => 'z'); + peer.listen(); + + incoming.add([ + { + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'bar': 'baz'}, + 'id': 0 + }, + { + 'jsonrpc': '2.0', + 'method': 'a', + 'params': {'b': 'c'}, + 'id': 1 + }, + { + 'jsonrpc': '2.0', + 'method': 'w', + 'params': {'x': 'y'}, + 'id': 2 + } + ]); + }); + + test('returns a response for malformed JSON', () { + var incomingController = StreamController(); + var outgoingController = StreamController(); + var jsonPeer = json_rpc.Peer( + StreamChannel(incomingController.stream, outgoingController)); + + expect( + outgoingController.stream.first.then(jsonDecode), + completion({ + 'jsonrpc': '2.0', + 'error': { + 'code': error_code.PARSE_ERROR, + 'message': startsWith('Invalid JSON: '), + // TODO(nweiz): Always expect the source when sdk#25655 is fixed. + 'data': { + 'request': anyOf([isNull, '{invalid']) + } + }, + 'id': null + })); + + jsonPeer.listen(); + + incomingController.add('{invalid'); + }); + + test('returns a response for incorrectly-structured JSON', () { + expect( + outgoing.first, + completion({ + 'jsonrpc': '2.0', + 'error': { + 'code': error_code.INVALID_REQUEST, + 'message': 'Request must contain a "jsonrpc" key.', + 'data': { + 'request': {'completely': 'wrong'} + } + }, + 'id': null + })); + + peer.listen(); + + incoming.add({'completely': 'wrong'}); + }); + }); + + test('can notify on unhandled errors for if the method throws', () async { + var exception = Exception('test exception'); + var incomingController = StreamController(); + var outgoingController = StreamController(); + final completer = Completer(); + peer = json_rpc.Peer.withoutJson( + StreamChannel(incomingController.stream, outgoingController), + onUnhandledError: (error, stack) { + completer.complete(error); + }, + ); + peer + ..registerMethod('foo', () => throw exception) + // ignore: unawaited_futures + ..listen(); + + incomingController.add({'jsonrpc': '2.0', 'method': 'foo'}); + var receivedException = await completer.future; + expect(receivedException, equals(exception)); + }); +} diff --git a/pkgs/json_rpc_2/test/server/batch_test.dart b/pkgs/json_rpc_2/test/server/batch_test.dart new file mode 100644 index 000000000..af883c48e --- /dev/null +++ b/pkgs/json_rpc_2/test/server/batch_test.dart @@ -0,0 +1,147 @@ +// 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. + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/src/parameters.dart' show Parameters; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late ServerController controller; + + setUp(() { + controller = ServerController(); + controller.server + ..registerMethod('foo', () => 'foo') + ..registerMethod('id', (Parameters params) => params.value) + ..registerMethod('arg', (Parameters params) => params['arg'].value); + }); + + test('handles a batch of requests', () { + expect( + controller.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( + controller.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( + controller.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( + controller.handleRequest([ + {'jsonrpc': '2.0', 'method': 'foo'}, + { + 'jsonrpc': '2.0', + 'method': 'id', + 'params': ['value'] + }, + { + 'jsonrpc': '2.0', + 'method': 'arg', + 'params': {'arg': 'value'} + } + ]), + doesNotComplete); + }); + + test('returns an error if the batch is empty', () { + expectErrorResponse(controller, [], error_code.INVALID_REQUEST, + 'A batch must contain at least one request.'); + }); + + test('disallows nested batches', () { + expect( + controller.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} + ] + } + } + } + ]))); + }); +} diff --git a/pkgs/json_rpc_2/test/server/invalid_request_test.dart b/pkgs/json_rpc_2/test/server/invalid_request_test.dart new file mode 100644 index 000000000..4fa4de1ed --- /dev/null +++ b/pkgs/json_rpc_2/test/server/invalid_request_test.dart @@ -0,0 +1,94 @@ +// 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. + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late ServerController controller; + setUp(() => controller = ServerController()); + + test('a non-Array/Object request is invalid', () { + expectErrorResponse(controller, 'foo', error_code.INVALID_REQUEST, + 'Request must be an Array or an Object.'); + }); + + test('requests must have a jsonrpc key', () { + expectErrorResponse(controller, {'method': 'foo', 'id': 1234}, + error_code.INVALID_REQUEST, 'Request must contain a "jsonrpc" key.'); + }); + + test('the jsonrpc version must be 2.0', () { + expectErrorResponse( + controller, + {'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(controller, {'jsonrpc': '2.0', 'id': 1234}, + error_code.INVALID_REQUEST, 'Request must contain a "method" key.'); + }); + + test('request method must be a string', () { + expectErrorResponse( + controller, + {'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( + controller, + {'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( + controller.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'} + } + } + } + }))); + }); + + group('strict protocol checks disabled', () { + setUp(() => controller = ServerController(strictProtocolChecks: false)); + + test('and no jsonrpc param', () { + expectErrorResponse(controller, {'method': 'foo', 'id': 1234}, + error_code.METHOD_NOT_FOUND, 'Unknown method "foo".'); + }); + + test('the jsonrpc version must be 2.0', () { + expectErrorResponse( + controller, + {'jsonrpc': '1.0', 'method': 'foo', 'id': 1234}, + error_code.INVALID_REQUEST, + 'Invalid JSON-RPC version "1.0", expected "2.0".'); + }); + }); +} diff --git a/pkgs/json_rpc_2/test/server/parameters_test.dart b/pkgs/json_rpc_2/test/server/parameters_test.dart new file mode 100644 index 000000000..9ecfb1ffa --- /dev/null +++ b/pkgs/json_rpc_2/test/server/parameters_test.dart @@ -0,0 +1,403 @@ +// 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. + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('with named parameters', () { + late json_rpc.Parameters parameters; + setUp(() { + parameters = json_rpc.Parameters('foo', { + 'num': 1.5, + 'int': 1, + 'bool': true, + 'string': 'zap', + 'list': [1, 2, 3], + 'date-time': '1990-01-01 00:00:00.000', + 'uri': 'https://dart.dev', + 'invalid-uri': 'http://[::1', + '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], + 'date-time': '1990-01-01 00:00:00.000', + 'uri': 'https://dart.dev', + 'invalid-uri': 'http://[::1', + '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({})); + }); + + test('[].asDateTime returns date/time parameters', () { + expect(parameters['date-time'].asDateTime, equals(DateTime(1990))); + }); + + test('[].asDateTimeOr returns date/time parameters', () { + expect(parameters['date-time'].asDateTimeOr(DateTime(2014)), + equals(DateTime(1990))); + }); + + test('[].asDateTime fails for non-date/time parameters', () { + expect( + () => parameters['int'].asDateTime, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'string, but was 1.')); + }); + + test('[].asDateTimeOr succeeds for absent parameters', () { + expect(parameters['fblthp'].asDateTimeOr(DateTime(2014)), + equals(DateTime(2014))); + }); + + test('[].asDateTime fails for non-date/time parameters', () { + expect( + () => parameters['int'].asDateTime, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'string, but was 1.')); + }); + + test('[].asDateTime fails for invalid date/times', () { + expect( + () => parameters['string'].asDateTime, + throwsInvalidParams('Parameter "string" for method "foo" must be a ' + 'valid date/time, but was "zap".\n' + 'Invalid date format')); + }); + + test('[].asUri returns URI parameters', () { + expect(parameters['uri'].asUri, equals(Uri.parse('https://dart.dev'))); + }); + + test('[].asUriOr returns URI parameters', () { + expect(parameters['uri'].asUriOr(Uri.parse('http://google.com')), + equals(Uri.parse('https://dart.dev'))); + }); + + test('[].asUri fails for non-URI parameters', () { + expect( + () => parameters['int'].asUri, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'string, but was 1.')); + }); + + test('[].asUriOr succeeds for absent parameters', () { + expect(parameters['fblthp'].asUriOr(Uri.parse('http://google.com')), + equals(Uri.parse('http://google.com'))); + }); + + test('[].asUri fails for non-URI parameters', () { + expect( + () => parameters['int'].asUri, + throwsInvalidParams('Parameter "int" for method "foo" must be a ' + 'string, but was 1.')); + }); + + test('[].asUri fails for invalid URIs', () { + expect( + () => parameters['invalid-uri'].asUri, + throwsInvalidParams('Parameter "invalid-uri" for method "foo" must ' + 'be a valid URI, but was "http://[::1".\n' + 'Missing end `]` to match `[` in host')); + }); + + group('with a nested parameter map', () { + late json_rpc.Parameter 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', () { + late json_rpc.Parameter 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', () { + late json_rpc.Parameters parameters; + setUp(() => parameters = 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 = 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/pkgs/json_rpc_2/test/server/server_test.dart b/pkgs/json_rpc_2/test/server/server_test.dart new file mode 100644 index 000000000..b3166ceb2 --- /dev/null +++ b/pkgs/json_rpc_2/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. + +import 'dart:convert'; + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late ServerController controller; + + setUp(() => controller = ServerController()); + + test('calls a registered method with the given name', () { + controller.server.registerMethod('foo', (json_rpc.Parameters params) { + return {'params': params.value}; + }); + + expect( + controller.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', () { + controller.server.registerMethod('foo', () => 'foo'); + + expect( + controller + .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}), + completion(equals({'jsonrpc': '2.0', 'result': 'foo', 'id': 1234}))); + }); + + test('Allows a `null` result', () { + controller.server.registerMethod('foo', () => null); + + expect( + controller + .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}), + completion(equals({'jsonrpc': '2.0', 'result': null, 'id': 1234}))); + }); + + test('a method that takes no parameters rejects parameters', () { + controller.server.registerMethod('foo', () => 'foo'); + + expectErrorResponse( + controller, + { + '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', () { + controller.server + .registerMethod('foo', () => throw const FormatException('bad format')); + + expect( + controller + .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': isA() + } + } + })); + }); + + test('doesn\'t return a result for a notification', () { + controller.server.registerMethod('foo', (args) => 'result'); + + expect( + controller.handleRequest( + {'jsonrpc': '2.0', 'method': 'foo', 'params': {}}), + doesNotComplete); + }); + + test('includes the error data in the response', () { + controller.server.registerMethod('foo', (params) { + throw json_rpc.RpcException(5, 'Error message.', data: 'data value'); + }); + + expectErrorResponse( + controller, + { + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {}, + 'id': 1234 + }, + 5, + 'Error message.', + data: 'data value'); + }); + + test('a JSON parse error is rejected', () { + return controller.handleJsonRequest('invalid json {').then((result) { + expect(jsonDecode(result), { + 'jsonrpc': '2.0', + 'error': { + 'code': error_code.PARSE_ERROR, + 'message': startsWith('Invalid JSON: '), + // TODO(nweiz): Always expect the source when sdk#25655 is fixed. + 'data': { + 'request': anyOf([isNull, 'invalid json {']) + } + }, + 'id': null + }); + }); + }); + + group('fallbacks', () { + test('calls a fallback if no method matches', () { + controller.server + ..registerMethod('foo', () => 'foo') + ..registerMethod('bar', () => 'foo') + ..registerFallback((params) => {'fallback': params.value}); + + expect( + controller.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', () { + controller.server + ..registerFallback((params) => + throw json_rpc.RpcException.methodNotFound(params.method)) + ..registerFallback((params) => 'fallback 2') + ..registerFallback((params) => 'fallback 3'); + + expect( + controller.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', () { + controller.server + .registerFallback((_) => throw const FormatException('bad format')); + + expect( + controller + .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': isA() + } + } + })); + }); + }); + + test('disallows multiple methods with the same name', () { + controller.server.registerMethod('foo', () => null); + expect(() => controller.server.registerMethod('foo', () => null), + throwsArgumentError); + }); +} diff --git a/pkgs/json_rpc_2/test/server/stream_test.dart b/pkgs/json_rpc_2/test/server/stream_test.dart new file mode 100644 index 000000000..832e13c7a --- /dev/null +++ b/pkgs/json_rpc_2/test/server/stream_test.dart @@ -0,0 +1,84 @@ +// 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. + +import 'dart:async'; + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +void main() { + late StreamController requestController; + late StreamController responseController; + late json_rpc.Server server; + + setUp(() { + requestController = StreamController(); + responseController = StreamController(); + server = json_rpc.Server.withoutJson( + StreamChannel(requestController.stream, responseController.sink)); + }); + + test('.withoutJson supports decoded stream and sink', () { + server.listen(); + + server.registerMethod('foo', (json_rpc.Parameters params) { + return {'params': params.value}; + }); + + requestController.add({ + 'jsonrpc': '2.0', + 'method': 'foo', + 'params': {'param': 'value'}, + 'id': 1234 + }); + + expect( + responseController.stream.first, + completion(equals({ + 'jsonrpc': '2.0', + 'result': { + 'params': {'param': 'value'} + }, + 'id': 1234 + }))); + }); + + test('.listen returns when the controller is closed', () { + var hasListenCompeted = false; + expect(server.listen().then((_) => hasListenCompeted = true), completes); + + return pumpEventQueue().then((_) { + expect(hasListenCompeted, isFalse); + + // This should cause listen to complete. + return requestController.close(); + }); + }); + + test('.listen returns a stream error', () { + expect(server.listen(), throwsA('oh no')); + requestController.addError('oh no'); + }); + + test('.listen can\'t be called twice', () { + server.listen(); + + expect(() => server.listen(), throwsStateError); + }); + + test('.close cancels the stream subscription and closes the sink', () { + // Work around sdk#19095. + responseController.stream.listen(null); + + expect(server.listen(), completes); + + expect(server.isClosed, isFalse); + expect(server.close(), completes); + expect(server.isClosed, isTrue); + + expect(() => requestController.stream.listen((_) {}), throwsStateError); + expect(responseController.isClosed, isTrue); + }); +} diff --git a/pkgs/json_rpc_2/test/server/utils.dart b/pkgs/json_rpc_2/test/server/utils.dart new file mode 100644 index 000000000..c94628e50 --- /dev/null +++ b/pkgs/json_rpc_2/test/server/utils.dart @@ -0,0 +1,70 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/error_code.dart' as error_code; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// A controller used to test a [json_rpc.Server]. +class ServerController { + /// The controller for the server's request stream. + final _requestController = StreamController(); + + /// The controller for the server's response sink. + final _responseController = StreamController(); + + /// The server. + late final json_rpc.Server server; + + ServerController( + {json_rpc.ErrorCallback? onUnhandledError, + bool strictProtocolChecks = true}) { + server = json_rpc.Server( + StreamChannel(_requestController.stream, _responseController.sink), + onUnhandledError: onUnhandledError, + strictProtocolChecks: strictProtocolChecks); + server.listen(); + } + + /// Passes [request], a decoded request, to [server] and returns its decoded + /// response. + Future handleRequest(Object? request) => + handleJsonRequest(jsonEncode(request)).then(jsonDecode); + + /// Passes [request], a JSON-encoded request, to [server] and returns its + /// encoded response. + Future handleJsonRequest(String request) { + _requestController.add(request); + return _responseController.stream.first; + } +} + +/// Expects that [controller]'s server will return an error response to +/// [request] with the given [errorCode], [message], and [data]. +void expectErrorResponse( + ServerController controller, Object? request, int errorCode, String message, + {Object? data}) { + dynamic id; + if (request is Map) id = request['id']; + data ??= {'request': request}; + + expect( + controller.handleRequest(request), + completion(equals({ + 'jsonrpc': '2.0', + 'id': id, + 'error': {'code': errorCode, 'message': message, 'data': data} + }))); +} + +/// Returns a matcher that matches a [json_rpc.RpcException] with an +/// `invalid_params` error code. +Matcher throwsInvalidParams(String message) => + throwsA(isA() + .having((e) => e.code, 'code', error_code.INVALID_PARAMS) + .having((e) => e.message, 'message', message));