Skip to content

Commit b3e5459

Browse files
authored
Merge pull request #1 from colanconnon/gevent-websocket
add a gevent websocket server (WIP)
2 parents 3df5301 + ae60b25 commit b3e5459

File tree

11 files changed

+337
-20
lines changed

11 files changed

+337
-20
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,6 @@ target/
6161
# pyenv python configuration file
6262
.python-version
6363
.DS_Store
64+
65+
.mypy_cache/
66+
.vscode/

examples/flask_gevent/__init__.py

Whitespace-only changes.

examples/flask_gevent/flask_app.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import json
2+
3+
import graphene
4+
from flask import Flask, make_response
5+
from flask_graphql import GraphQLView
6+
from flask_sockets import Sockets
7+
from rx import Observable
8+
9+
from graphql_ws import GeventSubscriptionServer
10+
from template import render_graphiql
11+
12+
13+
class Query(graphene.ObjectType):
14+
base = graphene.String()
15+
16+
17+
class RandomType(graphene.ObjectType):
18+
seconds = graphene.Int()
19+
random_int = graphene.Int()
20+
21+
22+
class Subscription(graphene.ObjectType):
23+
24+
count_seconds = graphene.Int(up_to=graphene.Int())
25+
26+
random_int = graphene.Field(RandomType)
27+
28+
29+
def resolve_count_seconds(root, info, up_to):
30+
return Observable.interval(1000)\
31+
.map(lambda i: "{0}".format(i))\
32+
.take_while(lambda i: int(i) <= up_to)
33+
34+
def resolve_random_int(root, info):
35+
import random
36+
return Observable.interval(1000).map(lambda i: RandomType(seconds=i, random_int=random.randint(0, 500)))
37+
38+
schema = graphene.Schema(query=Query, subscription=Subscription)
39+
40+
41+
42+
app = Flask(__name__)
43+
app.debug = True
44+
sockets = Sockets(app)
45+
46+
47+
@app.route('/graphiql')
48+
def graphql_view():
49+
return make_response(render_graphiql())
50+
51+
app.add_url_rule(
52+
'/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, graphiql=False))
53+
54+
subscription_server = GeventSubscriptionServer(schema)
55+
app.app_protocol = lambda environ_path_info: 'graphql-ws'
56+
57+
@sockets.route('/subscriptions')
58+
def echo_socket(ws):
59+
subscription_server.handle(ws)
60+
return []
61+
62+
63+
if __name__ == "__main__":
64+
from gevent import pywsgi
65+
from geventwebsocket.handler import WebSocketHandler
66+
server = pywsgi.WSGIServer(('', 5000), app, handler_class=WebSocketHandler)
67+
server.serve_forever()

examples/flask_gevent/template.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
2+
from string import Template
3+
4+
5+
def render_graphiql():
6+
return Template('''
7+
<!DOCTYPE html>
8+
<html>
9+
<head>
10+
<meta charset="utf-8" />
11+
<title>GraphiQL</title>
12+
<meta name="robots" content="noindex" />
13+
<style>
14+
html, body {
15+
height: 100%;
16+
margin: 0;
17+
overflow: hidden;
18+
width: 100%;
19+
}
20+
</style>
21+
<link href="//cdn.jsdelivr.net/graphiql/${GRAPHIQL_VERSION}/graphiql.css" rel="stylesheet" />
22+
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
23+
<script src="//cdn.jsdelivr.net/react/15.0.0/react.min.js"></script>
24+
<script src="//cdn.jsdelivr.net/react/15.0.0/react-dom.min.js"></script>
25+
<script src="//cdn.jsdelivr.net/graphiql/${GRAPHIQL_VERSION}/graphiql.min.js"></script>
26+
<script src="//unpkg.com/subscriptions-transport-ws@${SUBSCRIPTIONS_TRANSPORT_VERSION}/browser/client.js"></script>
27+
<script src="//unpkg.com/graphiql-subscriptions-fetcher@0.0.2/browser/client.js"></script>
28+
</head>
29+
<body>
30+
<script>
31+
// Collect the URL parameters
32+
var parameters = {};
33+
window.location.search.substr(1).split('&').forEach(function (entry) {
34+
var eq = entry.indexOf('=');
35+
if (eq >= 0) {
36+
parameters[decodeURIComponent(entry.slice(0, eq))] =
37+
decodeURIComponent(entry.slice(eq + 1));
38+
}
39+
});
40+
// Produce a Location query string from a parameter object.
41+
function locationQuery(params, location) {
42+
return (location ? location: '') + '?' + Object.keys(params).map(function (key) {
43+
return encodeURIComponent(key) + '=' +
44+
encodeURIComponent(params[key]);
45+
}).join('&');
46+
}
47+
// Derive a fetch URL from the current URL, sans the GraphQL parameters.
48+
var graphqlParamNames = {
49+
query: true,
50+
variables: true,
51+
operationName: true
52+
};
53+
var otherParams = {};
54+
for (var k in parameters) {
55+
if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
56+
otherParams[k] = parameters[k];
57+
}
58+
}
59+
var fetcher;
60+
if (true) {
61+
var subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient('${subscriptionsEndpoint}', {
62+
reconnect: true
63+
});
64+
fetcher = window.GraphiQLSubscriptionsFetcher.graphQLFetcher(subscriptionsClient, graphQLFetcher);
65+
} else {
66+
fetcher = graphQLFetcher;
67+
}
68+
// We don't use safe-serialize for location, because it's not client input.
69+
var fetchURL = locationQuery(otherParams, '${endpointURL}');
70+
// Defines a GraphQL fetcher using the fetch API.
71+
function graphQLFetcher(graphQLParams) {
72+
return fetch(fetchURL, {
73+
method: 'post',
74+
headers: {
75+
'Accept': 'application/json',
76+
'Content-Type': 'application/json',
77+
},
78+
body: JSON.stringify(graphQLParams),
79+
credentials: 'include',
80+
}).then(function (response) {
81+
return response.text();
82+
}).then(function (responseBody) {
83+
try {
84+
return JSON.parse(responseBody);
85+
} catch (error) {
86+
return responseBody;
87+
}
88+
});
89+
}
90+
// When the query and variables string is edited, update the URL bar so
91+
// that it can be easily shared.
92+
function onEditQuery(newQuery) {
93+
parameters.query = newQuery;
94+
updateURL();
95+
}
96+
function onEditVariables(newVariables) {
97+
parameters.variables = newVariables;
98+
updateURL();
99+
}
100+
function onEditOperationName(newOperationName) {
101+
parameters.operationName = newOperationName;
102+
updateURL();
103+
}
104+
function updateURL() {
105+
history.replaceState(null, null, locationQuery(parameters) + window.location.hash);
106+
}
107+
// Render <GraphiQL /> into the body.
108+
ReactDOM.render(
109+
React.createElement(GraphiQL, {
110+
fetcher: fetcher,
111+
onEditQuery: onEditQuery,
112+
onEditVariables: onEditVariables,
113+
onEditOperationName: onEditOperationName,
114+
}),
115+
document.body
116+
);
117+
</script>
118+
</body>
119+
</html>''').substitute(
120+
GRAPHIQL_VERSION='0.10.2',
121+
SUBSCRIPTIONS_TRANSPORT_VERSION='0.7.0',
122+
subscriptionsEndpoint='ws://localhost:5000/subscriptions',
123+
# subscriptionsEndpoint='ws://localhost:5000/',
124+
endpointURL='/graphql',
125+
)

examples/src/graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit ebcd7f29878f995b3cfd91050f2117a87c368e47

graphql_ws/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99

1010
from .observable_aiter import setup_observable_extension
1111
from .server import WebSocketSubscriptionServer
12-
12+
from .gevent_server import GeventSubscriptionServer
1313

1414
setup_observable_extension()

graphql_ws/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
GRAPHQL_WS = 'graphql-ws'
2+
WS_PROTOCOL = GRAPHQL_WS
3+
4+
GQL_CONNECTION_INIT = 'connection_init' # Client -> Server
5+
GQL_CONNECTION_ACK = 'connection_ack' # Server -> Client
6+
GQL_CONNECTION_ERROR = 'connection_error' # Server -> Client
7+
8+
# NOTE: This one here don't follow the standard due to connection optimization
9+
GQL_CONNECTION_TERMINATE = 'connection_terminate' # Client -> Server
10+
GQL_CONNECTION_KEEP_ALIVE = 'ka' # Server -> Client
11+
GQL_START = 'start' # Client -> Server
12+
GQL_DATA = 'data' # Server -> Client
13+
GQL_ERROR = 'error' # Server -> Client
14+
GQL_COMPLETE = 'complete' # Server -> Client
15+
GQL_STOP = 'stop' # Client -> Server

graphql_ws/gevent_server.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import json
2+
3+
from graphql import format_error, graphql
4+
from graphql.execution.executors.sync import SyncExecutor
5+
from rx import Observer, Observable
6+
from .server import BaseWebSocketSubscriptionServer, ConnectionContext, ConnectionClosedException
7+
from .constants import *
8+
9+
10+
class GEventConnectionContext(ConnectionContext):
11+
12+
def receive(self):
13+
msg = self.ws.receive()
14+
return msg
15+
16+
def send(self, data):
17+
if self.closed:
18+
return
19+
self.ws.send(data)
20+
21+
@property
22+
def closed(self):
23+
return self.ws.closed
24+
25+
def close(self, code):
26+
self.ws.close(code)
27+
28+
class GeventSubscriptionServer(BaseWebSocketSubscriptionServer):
29+
30+
def get_graphql_params(self, *args, **kwargs):
31+
params = super(GeventSubscriptionServer, self).get_graphql_params(*args, **kwargs)
32+
return dict(params, executor=SyncExecutor())
33+
34+
def handle(self, ws):
35+
connection_context = GEventConnectionContext(ws)
36+
self.on_open(connection_context)
37+
while True:
38+
try:
39+
if connection_context.closed:
40+
raise ConnectionClosedException()
41+
message = connection_context.receive()
42+
except ConnectionClosedException:
43+
self.on_close(connection_context)
44+
return
45+
self.on_message(connection_context, message)
46+
47+
def on_message(self, connection_context, message):
48+
try:
49+
parsed_message = json.loads(message)
50+
assert isinstance(
51+
parsed_message, dict), "Payload must be an object."
52+
except Exception as e:
53+
self.send_error(connection_context, None, e)
54+
return
55+
56+
self.process_message(connection_context, parsed_message)
57+
58+
def on_open(self, connection_context):
59+
pass
60+
61+
def on_connect(self, connection_context, payload):
62+
pass
63+
64+
def on_close(self, connection_context):
65+
remove_operations = list(connection_context.operations.keys())
66+
for op_id in remove_operations:
67+
self.unsubscribe(connection_context, op_id)
68+
69+
def on_connection_init(self, connection_context, op_id, payload):
70+
try:
71+
self.on_connect(connection_context, payload)
72+
self.send_message(connection_context, op_type=GQL_CONNECTION_ACK)
73+
74+
except Exception as e:
75+
self.send_error(connection_context, op_id, e, GQL_CONNECTION_ERROR)
76+
connection_context.close(1011)
77+
78+
def on_connection_terminate(self, connection_context, op_id):
79+
connection_context.close(1011)
80+
81+
82+
def on_start(self, connection_context, op_id, params):
83+
try:
84+
execution_result = graphql(
85+
self.schema, **params, allow_subscriptions=True
86+
)
87+
assert isinstance(
88+
execution_result, Observable), "A subscription must return an observable"
89+
execution_result.subscribe(SubscriptionObserver(
90+
connection_context,
91+
op_id,
92+
self.send_execution_result,
93+
self.send_error,
94+
self.on_close
95+
)
96+
)
97+
except Exception as e:
98+
self.send_error(connection_context, op_id, str(e))
99+
100+
def on_stop(self, connection_context, op_id):
101+
self.unsubscribe(connection_context, op_id)
102+
103+
104+
class SubscriptionObserver(Observer):
105+
106+
def __init__(self, connection_context, op_id, send_execution_result, send_error, on_close):
107+
self.connection_context = connection_context
108+
self.op_id = op_id
109+
self.send_execution_result = send_execution_result
110+
self.send_error = send_error
111+
self.on_close = on_close
112+
113+
def on_next(self, value):
114+
self.send_execution_result(self.connection_context, self.op_id, value)
115+
116+
def on_completed(self):
117+
self.on_close(self.connection_context)
118+
119+
def on_error(self, error):
120+
self.send_error(self.connection_context, self.op_id, error)

graphql_ws/server.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,12 @@
33
from websockets.protocol import CONNECTING, OPEN
44
from inspect import isawaitable, isasyncgen
55
from graphql import graphql, format_error
6+
from graphql.execution import ExecutionResult
67
from collections import OrderedDict
78
import json
9+
from .constants import *
810

911

10-
GRAPHQL_WS = 'graphql-ws'
11-
WS_PROTOCOL = GRAPHQL_WS
12-
13-
GQL_CONNECTION_INIT = 'connection_init' # Client -> Server
14-
GQL_CONNECTION_ACK = 'connection_ack' # Server -> Client
15-
GQL_CONNECTION_ERROR = 'connection_error' # Server -> Client
16-
17-
# NOTE: This one here don't follow the standard due to connection optimization
18-
GQL_CONNECTION_TERMINATE = 'connection_terminate' # Client -> Server
19-
GQL_CONNECTION_KEEP_ALIVE = 'ka' # Server -> Client
20-
GQL_START = 'start' # Client -> Server
21-
GQL_DATA = 'data' # Server -> Client
22-
GQL_ERROR = 'error' # Server -> Client
23-
GQL_COMPLETE = 'complete' # Server -> Client
24-
GQL_STOP = 'stop' # Client -> Server
25-
2612

2713
class ConnectionClosedException(Exception):
2814
pass
@@ -48,6 +34,7 @@ def get_operation(self, op_id):
4834
def remove_operation(self, op_id):
4935
del self.operations[op_id]
5036

37+
5138

5239
class AioHTTPConnectionContext(ConnectionContext):
5340
async def receive(self):
@@ -186,6 +173,7 @@ def on_operation_complete(self, connection_context, op_id):
186173
pass
187174

188175

176+
189177
class WebSocketSubscriptionServer(BaseWebSocketSubscriptionServer):
190178

191179
def get_graphql_params(self, *args, **kwargs):

0 commit comments

Comments
 (0)