Mojolicious-Plugin-GraphQL/lib/Mojolicious/Plugin/GraphQL.pm
package Mojolicious::Plugin::GraphQL;
# ABSTRACT: a plugin for adding GraphQL route handlers
use strict;
use warnings;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::JSON qw(decode_json to_json);
use GraphQL::Execution qw(execute);
use GraphQL::Type::Library -all;
use GraphQL::Debug qw(_debug);
use Module::Runtime qw(require_module);
use Mojo::Promise;
use curry;
use Exporter 'import';
our $VERSION = '0.19';
our @EXPORT_OK = qw(promise_code);
use constant DEBUG => $ENV{GRAPHQL_DEBUG};
use constant promise_code => +{
all => sub {
# current Mojo::Promise->all only works on promises, force that
my @promises = map is_Promise($_)
? $_ : Mojo::Promise->new->resolve($_),
@_;
# only actually works when first promise-instance is a
# Mojo::Promise, so force it to be one. hoping will be fixed soon
Mojo::Promise->all(@promises);
},
resolve => Mojo::Promise->curry::resolve,
reject => Mojo::Promise->curry::reject,
new => Mojo::Promise->curry::new,
};
# from https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/message-types.ts
use constant ws_protocol => +{
# no legacy ones like 'init'
GQL_CONNECTION_INIT => 'connection_init', # Client -> Server
GQL_CONNECTION_ACK => 'connection_ack', # Server -> Client
GQL_CONNECTION_ERROR => 'connection_error', # Server -> Client
# NOTE: The keep alive message type does not follow the standard due to connection optimizations
GQL_CONNECTION_KEEP_ALIVE => 'ka', # Server -> Client
GQL_CONNECTION_TERMINATE => 'connection_terminate', # Client -> Server
GQL_START => 'start', # Client -> Server
GQL_DATA => 'data', # Server -> Client
GQL_ERROR => 'error', # Server -> Client
GQL_COMPLETE => 'complete', # Server -> Client
GQL_STOP => 'stop', # Client -> Server
};
my @DEFAULT_METHODS = qw(get post);
use constant EXECUTE => sub { $_[7] = promise_code(); goto &execute; };
use constant SUBSCRIBE => sub {
splice @_, 7, 1, promise_code();
goto &GraphQL::Subscription::subscribe;
};
sub make_code_closure {
my ($schema, $root_value, $field_resolver) = @_;
sub {
my ($c, $body, $execute, $subscribe_resolver) = @_;
$execute->(
$schema,
$body->{query},
$root_value,
$c->req->headers,
$body->{variables},
$body->{operationName},
$field_resolver,
$subscribe_resolver ? (undef, $subscribe_resolver) : (),
);
};
}
sub _safe_serialize {
my $data = shift // return 'undefined';
my $json = to_json($data);
$json =~ s#/#\\/#g;
return $json;
}
sub _graphiql_wrap {
my ($wrappee, $use_subscription) = @_;
sub {
my ($c) = @_;
if (
# not as ignores Firefox-sent multi-accept: $c->accepts('', 'html') and
($c->req->headers->header('Accept')//'') =~ /^text\/html\b/ and
!defined $c->req->query_params->param('raw')
) {
my $p = $c->req->query_params;
my $https = $c->req->is_secure;
return $c->render(
template => 'graphiql',
layout => undef,
title => 'GraphiQL',
graphiql_version => 'latest',
queryString => _safe_serialize( $p->param('query') ),
operationName => _safe_serialize( $p->param('operationName') ),
resultString => _safe_serialize( $p->param('result') ),
variablesString => _safe_serialize( $p->param('variables') ),
subscriptionEndpoint => to_json(
# if serialises to true (which empty-string will), turns on subs code
$use_subscription
? $c->url_for->to_abs->scheme($https ? 'wss' : 'ws')
: 0
),
);
}
goto $wrappee;
};
}
sub _decode {
my ($bytes) = @_;
my $body = eval { decode_json($bytes) };
# conceal error info like versions from attackers
return (0, { errors => [ { message => "Malformed request" } ] }) if $@;
(1, $body);
}
sub _execute {
my ($c, $body, $handler, $execute, $subscribe_fn) = @_;
my $data = eval { $handler->($c, $body, $execute, $subscribe_fn) };
return { errors => [ { message => $@ } ] } if $@;
$data;
}
sub _make_route_handler {
my ($handler) = @_;
sub {
my ($c) = @_;
my ($decode_ok, $body) = _decode($c->req->body);
return $c->render(json => $body) if !$decode_ok;
my $data = _execute($c, $body, $handler, EXECUTE());
return $c->render(json => $data) if !is_Promise($data);
$data->then(sub { $c->render(json => shift) });
};
}
sub _make_connection_handler {
my ($handler, $subscribe_resolver, $context) = @_;
sub {
my ($c, $bytes) = @_;
my ($decode_ok, $body) = _decode($bytes);
return $c->send({json => {
payload => $body,
type => ($context->{connected}
? ws_protocol->{GQL_ERROR} : ws_protocol->{GQL_CONNECTION_ERROR}),
}}) if !$decode_ok;
my $msg_type = $body->{type};
if ($msg_type eq ws_protocol->{GQL_CONNECTION_INIT}) {
$context->{connected} = 1;
$c->send({json => {
type => ws_protocol->{GQL_CONNECTION_ACK}
}});
if ($context->{keepalive}) {
my $cb;
$cb = sub {
return unless $c->tx and $context->{keepalive};
$c->send({json => {
type => ws_protocol->{GQL_CONNECTION_KEEP_ALIVE},
}});
Mojo::IOLoop->timer($context->{keepalive} => $cb);
};
$cb->();
}
return;
} elsif ($msg_type eq ws_protocol->{GQL_START}) {
$context->{id} = $body->{id};
my $data = _execute(
$c, $body->{payload}, $handler, SUBSCRIBE(), $subscribe_resolver,
);
return $c->send({json => {
payload => $data, type => ws_protocol->{GQL_ERROR},
}}) if !is_Promise($data);
$data->then(
sub {
my ($result) = @_;
if (!is_AsyncIterator($result)) {
# subscription error
$c->send({json => {
payload => $result, type => ws_protocol->{GQL_ERROR},
id => $context->{id},
}});
$c->finish;
return;
}
my $promise;
$context->{async_iterator} = $result->map_then(sub {
DEBUG and _debug('MLPlugin.ai_cb', $context, @_);
$c->send({json => {
payload => $_[0],
type => ws_protocol->{GQL_DATA},
id => $context->{id},
}});
$promise = $context->{async_iterator}->next_p;
$c->send({json => {
type => ws_protocol->{GQL_COMPLETE},
id => $context->{id},
}}) if !$promise; # exhausted, tell client
});
$promise = $context->{async_iterator}->next_p; # start the process
},
sub {
$c->send({json => {
payload => $_[0], type => ws_protocol->{GQL_ERROR},
id => $context->{id},
}});
$c->finish;
},
);
} elsif ($msg_type eq ws_protocol->{GQL_STOP}) {
$c->send({json => {
type => ws_protocol->{GQL_COMPLETE},
id => $context->{id},
}});
$context->{async_iterator}->close_tap if $context->{async_iterator};
undef %$context; # relinquish our refcounts
}
}
}
sub _make_subs_route_handler {
my ($handler, $subscribe_resolver, $keepalive) = @_;
require GraphQL::Subscription;
sub {
my ($c) = @_;
# without this, GraphiQL won't accept is valid
my $sec_websocket_protocol = $c->tx->req->headers->sec_websocket_protocol;
$c->tx->res->headers->sec_websocket_protocol($sec_websocket_protocol)
if $sec_websocket_protocol;
my %context = (keepalive => $keepalive);
$c->on(text => _make_connection_handler($handler, $subscribe_resolver, \%context));
$c->on(finish => sub {
$context{async_iterator}->close_tap if $context{async_iterator};
undef %context; # relinquish our refcounts
});
};
}
sub register {
my ($self, $app, $conf) = @_;
if ($conf->{convert}) {
my ($class, @values) = @{ $conf->{convert} };
$class = "GraphQL::Plugin::Convert::$class";
require_module $class;
my $converted = $class->to_graphql(@values);
$conf = { %$conf, %$converted };
}
die "Need schema\n" if !$conf->{schema};
my $endpoint = $conf->{endpoint} || '/graphql';
my $handler = $conf->{handler} || make_code_closure(
@{$conf}{qw(schema root_value resolver)}
);
push @{$app->renderer->classes}, __PACKAGE__
unless grep $_ eq __PACKAGE__, @{$app->renderer->classes};
my $route_handler = _make_route_handler($handler);
$route_handler = _graphiql_wrap($route_handler, $conf->{schema}->subscription)
if $conf->{graphiql};
my $r = $app->routes;
if ($conf->{schema}->subscription) {
# must add "websocket" route before "any" because checked in define order
my $subs_route_handler = _make_subs_route_handler(
$handler, @{$conf}{qw(subscribe_resolver keepalive)},
);
$r->websocket($endpoint => $subs_route_handler);
}
$r->any(\@DEFAULT_METHODS => $endpoint => $route_handler);
}
1;
=encoding utf8
=head1 NAME
Mojolicious::Plugin::GraphQL - a plugin for adding GraphQL route handlers
=head1 SYNOPSIS
my $schema = GraphQL::Schema->from_doc(<<'EOF');
schema {
query: QueryRoot
}
type QueryRoot {
helloWorld: String
}
EOF
# for Mojolicious substitute "plugin" with $app->plugin(...
# Mojolicious::Lite (with endpoint under "/graphql")
plugin GraphQL => {
schema => $schema, root_value => { helloWorld => 'Hello, world!' }
};
# OR, equivalently:
plugin GraphQL => {schema => $schema, handler => sub {
my ($c, $body, $execute, $subscribe_fn) = @_;
# returns JSON-able Perl data
$execute->(
$schema,
$body->{query},
{ helloWorld => 'Hello, world!' }, # $root_value
$c->req->headers,
$body->{variables},
$body->{operationName},
undef, # $field_resolver
$subscribe_fn ? (undef, $subscribe_fn) : (), # only passed for subs
);
}};
# OR, with bespoke user-lookup and caching:
plugin GraphQL => {schema => $schema, handler => sub {
my ($c, $body, $execute, $subscribe_fn) = @_;
my $user = MyStuff::User->lookup($app->request->headers->header('X-Token'));
die "Invalid user\n" if !$user; # turned into GraphQL { errors => [ ... ] }
my $cached_result = MyStuff::RequestCache->lookup($user, $body->{query});
return $cached_result if $cached_result;
MyStuff::RequestCache->cache_and_return($execute->(
$schema,
$body->{query},
undef, # $root_value
$user, # per-request info
$body->{variables},
$body->{operationName},
undef, # $field_resolver
$subscribe_fn ? (undef, $subscribe_fn) : (), # only passed for subs
));
};
# With GraphiQL, on /graphql
plugin GraphQL => {schema => $schema, graphiql => 1};
=head1 DESCRIPTION
This plugin allows you to easily define a route handler implementing a
GraphQL endpoint, including a websocket for subscriptions following
Apollo's C<subscriptions-transport-ws> protocol.
As of version 0.09, it will supply the necessary C<promise_code>
parameter to L<GraphQL::Execution/execute>. This means your resolvers
can (and indeed should) return Promise objects to function
asynchronously. As of 0.15 these must be "Promises/A+" as subscriptions
require C<resolve> and C<reject> methods.
The route handler code will be compiled to behave like the following:
=over 4
=item *
Passes to the L<GraphQL> execute, possibly via your supplied handler,
the given schema, C<$root_value> and C<$field_resolver>. Note as above
that the wrapper used in this plugin will supply the hash-ref matching
L<GraphQL::Type::Library/PromiseCode>.
=item *
The action built matches POST / GET requests.
=item *
Returns GraphQL results in JSON form.
=back
=head1 OPTIONS
L<Mojolicious::Plugin::GraphQL> supports the following options.
=head2 convert
Array-ref. First element is a classname-part, which will be prepended with
"L<GraphQL::Plugin::Convert>::". The other values will be passed
to that class's L<GraphQL::Plugin::Convert/to_graphql> method. The
returned hash-ref will be used to set options, particularly C<schema>,
and probably at least one of C<resolver> and C<root_value>.
=head2 endpoint
String. Defaults to C</graphql>.
=head2 schema
A L<GraphQL::Schema> object. As of 0.15, must be supplied.
=head2 root_value
An optional root value, passed to top-level resolvers.
=head2 resolver
An optional field resolver, replacing the GraphQL default.
=head2 handler
An optional route-handler, replacing the plugin's default - see example
above for possibilities.
It must return JSON-able Perl data in the GraphQL format, which is a hash
with at least one of a C<data> key and/or an C<errors> key.
If it throws an exception, that will be turned into a GraphQL-formatted
error.
If being used for a subscription, it will be called with a fourth
parameter as shown above. It is safe to not handle this if you are
content with GraphQL's defaults.
=head2 graphiql
Boolean controlling whether requesting the endpoint with C<Accept:
text/html> will return the GraphiQL user interface. Defaults to false.
# Mojolicious::Lite
plugin GraphQL => {schema => $schema, graphiql => 1};
=head2 keepalive
Defaults to 0, which means do not send. Otherwise will send a keep-alive
packet over websocket every specified number of seconds.
=head1 METHODS
L<Mojolicious::Plugin::GraphQL> inherits all methods from
L<Mojolicious::Plugin> and implements the following new ones.
=head2 register
my $route = $plugin->register(Mojolicious->new, {schema => $schema});
Register renderer in L<Mojolicious> application.
=head1 EXPORTS
Exportable is the function C<promise_code>, which returns a hash-ref
suitable for passing as the 8th argument to L<GraphQL::Execution/execute>.
=head1 SUBSCRIPTIONS
To use subscriptions within your web app, just insert this JavaScript:
<script src="//unpkg.com/subscriptions-transport-ws@0.9.16/browser/client.js"></script>
# ...
const subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient(websocket_uri, {
reconnect: true
});
subscriptionsClient.request({
query: "subscription s($c: [String!]) {subscribe(channels: $c) {channel username dateTime message}}",
variables: { c: channel },
}).subscribe({
next(payload) {
var msg = payload.data.subscribe;
console.log(msg.username + ' said', msg.message);
},
error: console.error,
});
Note the use of parameterised queries, where you only need to change
the C<variables> parameter. The above is adapted from the sample app,
L<https://github.com/graphql-perl/sample-mojolicious>.
=head1 SEE ALSO
L<GraphQL>
L<GraphQL::Plugin::Convert>
L<https://github.com/apollographql/subscriptions-transport-ws#client-browser>
- Apollo documentation
=head1 AUTHOR
Ed J
Based heavily on L<Mojolicious::Plugin::PODRenderer>.
=head1 COPYRIGHT AND LICENSE
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut
1;
__DATA__
@@ graphiql.html.ep
<!--
Copied from https://github.com/graphql/express-graphql/blob/master/src/renderGraphiQL.js
Converted to use the simple template to capture the CGI args
Added the apollo-link-ws stuff, marked with "ADDED"
-->
<!--
The request to this GraphQL server provided the header "Accept: text/html"
and as a result has been presented GraphiQL - an in-browser IDE for
exploring GraphQL.
If you wish to receive JSON, provide the header "Accept: application/json" or
add "&raw" to the end of the URL within a browser.
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GraphiQL</title>
<meta name="robots" content="noindex" />
<style>
html, body {
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
</style>
<link href="//cdn.jsdelivr.net/npm/graphiql@<%= $graphiql_version %>/graphiql.css" rel="stylesheet" />
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql@<%= $graphiql_version %>/graphiql.min.js"></script>
<% if ($subscriptionEndpoint) { %>
<!-- ADDED -->
<script src="//unpkg.com/subscriptions-transport-ws@0.9.16/browser/client.js"></script>
<% } %>
</head>
<body>
<script type="module">
// Collect the URL parameters
var parameters = {};
window.location.search.substr(1).split('&').forEach(function (entry) {
var eq = entry.indexOf('=');
if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] =
decodeURIComponent(entry.slice(eq + 1));
}
});
// Produce a Location query string from a parameter object.
function locationQuery(params) {
return '?' + Object.keys(params).filter(function (key) {
return Boolean(params[key]);
}).map(function (key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(params[key]);
}).join('&');
}
// Derive a fetch URL from the current URL, sans the GraphQL parameters.
var graphqlParamNames = {
query: true,
variables: true,
operationName: true
};
var otherParams = {};
for (var k in parameters) {
if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
otherParams[k] = parameters[k];
}
}
var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
return fetch(fetchURL, {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(graphQLParams),
credentials: 'include',
}).then(function (response) {
return response.text();
}).then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
// When the query and variables string is edited, update the URL bar so
// that it can be easily shared.
function onEditQuery(newQuery) {
parameters.query = newQuery;
updateURL();
}
function onEditVariables(newVariables) {
parameters.variables = newVariables;
updateURL();
}
function onEditOperationName(newOperationName) {
parameters.operationName = newOperationName;
updateURL();
}
function updateURL() {
history.replaceState(null, null, locationQuery(parameters));
}
// this section ADDED
<% if ($subscriptionEndpoint) { %>
// this replaces the apollo GraphiQL-Subscriptions-Fetcher which is now incompatible with 0.6+ of subscriptions-transport-ws
// based on matiasanaya PR to fix but with improvement to only look at definition of operation being executed
import { parse } from "//unpkg.com/graphql@15.0.0/language/index.mjs";
const subsGraphQLFetcher = (subscriptionsClient, fallbackFetcher) => {
const hasSubscriptionOperation = (graphQlParams) => {
const thisOperation = graphQlParams.operationName;
const queryDoc = parse(graphQlParams.query);
const opDefinitions = queryDoc.definitions.filter(
x => x.kind === 'OperationDefinition'
);
const thisDefinition = opDefinitions.length == 1
? opDefinitions[0]
: opDefinitions.filter(x => x.name.value === thisOperation)[0];
return thisDefinition.operation === 'subscription';
};
let activeSubscription = false;
return (graphQLParams) => {
if (subscriptionsClient && activeSubscription) {
subscriptionsClient.unsubscribeAll();
}
if (subscriptionsClient && hasSubscriptionOperation(graphQLParams)) {
activeSubscription = true;
return subscriptionsClient.request(graphQLParams);
} else {
return fallbackFetcher(graphQLParams);
}
};
};
var subscriptionEndpoint = <%== $subscriptionEndpoint %>;
let subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient(subscriptionEndpoint, {
lazy: true, // not in original
reconnect: true
});
let myCustomFetcher = subsGraphQLFetcher(subscriptionsClient, graphQLFetcher);
<% } else { %>
let myCustomFetcher = graphQLFetcher;
<% } %>
// end ADDED
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: myCustomFetcher, // ADDED changed from graphQLFetcher
onEditQuery: onEditQuery,
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName,
query: <%== $queryString %>,
response: <%== $resultString %>,
variables: <%== $variablesString %>,
operationName: <%== $operationName %>,
}),
document.body
);
</script>
</body>
</html>