apollo patch

This commit is contained in:
Tobe O 2019-04-18 00:12:52 -04:00
parent 37d58529c2
commit 7385aacc59
6 changed files with 174 additions and 87 deletions

View file

@ -85,8 +85,9 @@ main() async {
// Mount GraphQL routes; we'll support HTTP and WebSockets transports.
app.get('/graphql', graphQLHttp(GraphQL(schema)));
app.get('/graphql/subscriptions', graphQLWS(GraphQL(schema)));
app.get('/graphiql', graphiQL());
app.get('/subscriptions', graphQLWS(GraphQL(schema)));
app.get('/graphiql',
graphiQL(subscriptionsEndpoint: 'ws://localhost:3000/subscriptions'));
var server = await http.startServer('127.0.0.1', 3000);
var uri =

View file

@ -5,16 +5,38 @@ import 'package:http_parser/http_parser.dart';
///
/// By default, the interface expects your backend to be mounted at `/graphql`; this is configurable
/// via [graphQLEndpoint].
RequestHandler graphiQL({String graphQLEndpoint: '/graphql'}) {
RequestHandler graphiQL(
{String graphQLEndpoint: '/graphql', String subscriptionsEndpoint}) {
return (req, res) {
res
..contentType = new MediaType('text', 'html')
..write(renderGraphiql(graphqlEndpoint: graphQLEndpoint))
..write(renderGraphiql(
graphqlEndpoint: graphQLEndpoint,
subscriptionsEndpoint: subscriptionsEndpoint))
..close();
};
}
String renderGraphiql({String graphqlEndpoint: '/graphql'}) {
String renderGraphiql(
{String graphqlEndpoint: '/graphql', String subscriptionsEndpoint}) {
var subscriptionsScripts = '',
subscriptionsFetcher = '',
fetcherName = 'graphQLFetcher';
if (subscriptionsEndpoint != null) {
fetcherName = 'subscriptionsFetcher';
subscriptionsScripts = '''
<script src="//unpkg.com/subscriptions-transport-ws@0.5.4/browser/client.js"></script>
<script src="//unpkg.com/graphiql-subscriptions-fetcher@0.0.2/browser/client.js"></script>
''';
subscriptionsFetcher = '''
let subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient('$subscriptionsEndpoint', {
reconnect: true
});
let $fetcherName = window.GraphiQLSubscriptionsFetcher.graphQLFetcher(subscriptionsClient, graphQLFetcher);
''';
}
return '''
<!DOCTYPE html>
<html lang="en">
@ -38,6 +60,7 @@ String renderGraphiql({String graphqlEndpoint: '/graphql'}) {
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.3/fetch.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/graphiql/0.11.11/graphiql.js"></script>
$subscriptionsScripts
<script>
window.onload = function() {
function graphQLFetcher(graphQLParams) {
@ -49,10 +72,11 @@ String renderGraphiql({String graphqlEndpoint: '/graphql'}) {
return response.json();
});
}
$subscriptionsFetcher
ReactDOM.render(
React.createElement(
GraphiQL,
{fetcher: graphQLFetcher}
{fetcher: $fetcherName}
),
document.getElementById('app')
);

View file

@ -18,15 +18,24 @@ import 'package:web_socket_channel/io.dart';
///
/// See:
/// * https://github.com/apollographql/subscriptions-transport-ws
RequestHandler graphQLWS(GraphQL graphQL) {
RequestHandler graphQLWS(GraphQL graphQL, {Duration keepAliveInterval}) {
return (req, res) async {
if (req is HttpRequestContext) {
if (WebSocketTransformer.isUpgradeRequest(req.rawRequest)) {
await res.detach();
var socket = await WebSocketTransformer.upgrade(req.rawRequest);
var socket = await WebSocketTransformer.upgrade(req.rawRequest,
protocolSelector: (protocols) {
if (protocols.contains('graphql-subscriptions'))
return 'graphql-subscriptions';
else
throw AngelHttpException.badRequest(
message:
'Only the "graphql-subscriptions" protocol is allowed.');
});
var channel = IOWebSocketChannel(socket);
var client = stw.RemoteClient(channel.cast<String>());
var server = _GraphQLWSServer(client, graphQL, req, res);
var server =
_GraphQLWSServer(client, graphQL, req, res, keepAliveInterval);
await server.done;
} else {
throw AngelHttpException.badRequest(
@ -44,8 +53,9 @@ class _GraphQLWSServer extends stw.Server {
final RequestContext req;
final ResponseContext res;
_GraphQLWSServer(stw.RemoteClient client, this.graphQL, this.req, this.res)
: super(client);
_GraphQLWSServer(stw.RemoteClient client, this.graphQL, this.req, this.res,
Duration keepAliveInterval)
: super(client, keepAliveInterval: keepAliveInterval);
@override
bool onConnect(stw.RemoteClient client, [Map connectionParams]) => true;

View file

@ -8,7 +8,10 @@ class RemoteClient extends StreamChannelMixin<OperationMessage> {
StreamChannelController();
RemoteClient.withoutJson(this.channel) {
_ctrl.local.stream.map((m) => m.toJson()).cast<Map>().pipe(channel.sink);
_ctrl.local.stream
.map((m) => m.toJson())
.cast<Map>()
.forEach(channel.sink.add);
channel.stream.listen((m) {
_ctrl.local.sink.add(OperationMessage.fromJson(m));
});

View file

@ -4,80 +4,109 @@ import 'transport.dart';
abstract class Server {
final RemoteClient client;
final Duration keepAliveInterval;
final Completer _done = Completer();
StreamSubscription<OperationMessage> _sub;
bool _init = false;
Future get done => _done.future;
Server(this.client) {
_sub = client.stream.listen((msg) async {
if (msg.type == OperationMessage.gqlConnectionInit && !_init) {
try {
Map connectionParams = null;
if (msg.payload is Map)
connectionParams = msg.payload as Map;
else if (msg.payload != null)
throw FormatException(
'${msg.type} payload must be a map (object).');
Server(this.client, {this.keepAliveInterval}) {
_sub = client.stream.listen(
(msg) async {
if ((msg.type == OperationMessage.gqlConnectionInit) && !_init) {
try {
Map connectionParams = null;
if (msg.payload is Map)
connectionParams = msg.payload as Map;
else if (msg.payload != null)
throw FormatException(
'${msg.type} payload must be a map (object).');
var connect = await onConnect(client, connectionParams);
if (!connect) throw false;
_init = true;
client.sink.add(OperationMessage(OperationMessage.gqlConnectionAck));
} catch (e) {
if (e == false)
_reportError('The connection was rejected.');
else
_reportError(e.toString());
}
} else if (_init) {
if (msg.type == OperationMessage.gqlStart) {
if (msg.id == null)
throw FormatException('${msg.type} id is required.');
if (msg.payload == null)
throw FormatException('${msg.type} payload is required.');
else if (msg.payload is! Map)
throw FormatException(
'${msg.type} payload must be a map (object).');
var payload = msg.payload as Map;
var query = payload['query'];
var variables = payload['variables'];
var operationName = payload['operationName'];
if (query == null || query is! String)
throw FormatException(
'${msg.type} payload must contain a string named "query".');
if (variables != null && variables is! Map)
throw FormatException(
'${msg.type} payload\'s "variables" field must be a map (object).');
if (operationName != null && operationName is! String)
throw FormatException(
'${msg.type} payload\'s "operationName" field must be a string.');
var result = await onOperation(
msg.id,
query as String,
(variables as Map).cast<String, dynamic>(),
operationName as String);
var data = result.data;
var connect = await onConnect(client, connectionParams);
if (!connect) throw false;
_init = true;
client.sink
.add(OperationMessage(OperationMessage.gqlConnectionAck));
if (data is Stream) {
await for (var event in data) {
client.sink.add(OperationMessage(OperationMessage.gqlData,
id: msg.id,
payload: {'data': event, 'errors': result.errors}));
// if (keepAliveInterval != null) {
// client.sink.add(
// OperationMessage(OperationMessage.gqlConnectionKeepAlive));
// }
} catch (e) {
if (e == false)
_reportError('The connection was rejected.');
else
_reportError(e.toString());
}
} else {
client.sink.add(OperationMessage(OperationMessage.gqlData,
id: msg.id, payload: {'data': data, 'errors': result.errors}));
}
} else if (_init) {
if (msg.type == OperationMessage.gqlStart) {
if (msg.id == null)
throw FormatException('${msg.type} id is required.');
if (msg.payload == null)
throw FormatException('${msg.type} payload is required.');
else if (msg.payload is! Map)
throw FormatException(
'${msg.type} payload must be a map (object).');
var payload = msg.payload as Map;
var query = payload['query'];
var variables = payload['variables'];
var operationName = payload['operationName'];
if (query == null || query is! String)
throw FormatException(
'${msg.type} payload must contain a string named "query".');
if (variables != null && variables is! Map)
throw FormatException(
'${msg.type} payload\'s "variables" field must be a map (object).');
if (operationName != null && operationName is! String)
throw FormatException(
'${msg.type} payload\'s "operationName" field must be a string.');
var result = await onOperation(
msg.id,
query as String,
(variables as Map)?.cast<String, dynamic>(),
operationName as String);
// var c = Completer();
// if (keepAliveInterval != null) {
// Timer.periodic(keepAliveInterval, (timer) {
// if (c.isCompleted) {
// timer.cancel();
// } else {
// client.sink.add(OperationMessage(
// OperationMessage.gqlConnectionKeepAlive,
// id: msg.id));
// }
// });
// }
client.sink
.add(OperationMessage(OperationMessage.gqlComplete, id: msg.id));
} else if (msg.type == OperationMessage.gqlConnectionTerminate) {
await _sub?.cancel();
}
}
}, onError: _done.completeError, onDone: _done.complete);
var data = result.data;
if (data is Stream) {
await for (var event in data) {
client.sink.add(OperationMessage(OperationMessage.gqlData,
id: msg.id,
payload: {'data': event, 'errors': result.errors}));
}
} else {
client.sink.add(OperationMessage(OperationMessage.gqlData,
id: msg.id,
payload: {'data': data, 'errors': result.errors}));
}
// c.complete();
client.sink.add(
OperationMessage(OperationMessage.gqlComplete, id: msg.id));
}
// TODO: https://github.com/apollographql/subscriptions-transport-ws/issues/551
// else if (msg.type == OperationMessage.gqlConnectionTerminate) {
// await _sub?.cancel();
// }
}
},
onError: _done.completeError,
onDone: () {
_done.complete();
});
}
void _reportError(String message) {

View file

@ -4,16 +4,28 @@ import 'package:stream_channel/stream_channel.dart';
/// A basic message in the Apollo WebSocket protocol.
class OperationMessage {
static const String gqlConnectionInit = 'GQL_CONNECTION_INIT',
gqlConnectionAck = 'GQL_CONNECTION_ACK',
gqlConnectionKeepAlive = 'GQL_CONNECTION_KEEP_ALIVE',
gqlConnectionError = 'GQL_CONNECTION_ERROR',
gqlStart = 'GQL_START',
gqlStop = 'GQL_STOP',
gqlConnectionTerminate = 'GQL_CONNECTION_TERMINATE',
gqlData = 'GQL_DATA',
gqlError = 'GQL_ERROR',
gqlComplete = 'GQL_COMPLETE';
static const String gqlConnectionInit = 'init',
gqlConnectionAck = 'init_success',
gqlConnectionKeepAlive = 'keepalive',
gqlConnectionError = 'init_fail',
gqlStart = 'subscription_start',
gqlStop = 'subscription_end',
// TODO: Does this have a replacement?
// https://github.com/apollographql/subscriptions-transport-ws/issues/551
// gqlConnectionTerminate = 'subscription_end',
gqlData = 'subscription_data',
gqlError = 'subscription_fail',
gqlComplete = 'subscription_success';
// static const String gqlConnectionInit = 'GQL_CONNECTION_INIT',
// gqlConnectionAck = 'GQL_CONNECTION_ACK',
// gqlConnectionKeepAlive = 'GQL_CONNECTION_KEEP_ALIVE',
// gqlConnectionError = 'GQL_CONNECTION_ERROR',
// gqlStart = 'GQL_START',
// gqlStop = 'GQL_STOP',
// gqlConnectionTerminate = 'GQL_CONNECTION_TERMINATE',
// gqlData = 'GQL_DATA',
// gqlError = 'GQL_ERROR',
// gqlComplete = 'GQL_COMPLETE';
final dynamic payload;
final String id;
final String type;
@ -29,8 +41,16 @@ class OperationMessage {
throw ArgumentError.notNull('type');
else if (type is! String)
throw ArgumentError.value(type, 'type', 'must be a string');
else if (id is num)
id = id.toString();
else if (id != null && id is! String)
throw ArgumentError.value(type, 'id', 'must be a string');
throw ArgumentError.value(id, 'id', 'must be a string or number');
// TODO: This is technically a violation of the spec.
// https://github.com/apollographql/subscriptions-transport-ws/issues/551
if (map.containsKey('query') ||
map.containsKey('operationName') ||
map.containsKey('variables')) payload = Map.from(map);
return OperationMessage(type as String, id: id as String, payload: payload);
}