Current section
Files
Jump to
Current section
Files
src/cowboy_graphql.erl
-module(cowboy_graphql).
-export([
graphql_error/3,
request_validation_error/2,
auth_error/2,
other_error/2,
http_config/3,
ws_config/3
]).
-export([
call_connection/3,
call_init/4,
call_handle_request/7,
call_handle_cancel/3,
call_handle_info/3,
call_terminate/3
]).
%% Internal helpers
-export([put_not_empty/3, protocol_error/4]).
-export_type([
result/0,
error/0,
request_validation_error/0,
auth_error/0,
protocol_error/0,
other_error/0,
graphql_error/0
]).
-export_type([
json_object/0,
json/0
]).
-type json() :: json_object() | json_array() | json_scalar().
-type json_object() :: #{json_string() => json()}.
-type json_array() :: [json()].
-type json_scalar() :: json_string() | integer() | float() | boolean() | null.
-type json_string() :: unicode:unicode_binary().
-type request_id() :: binary().
-type handler_state() :: any().
-type error() ::
request_validation_error()
| auth_error()
| protocol_error()
| other_error().
%% Only types of error which are allowed to be returned from callbacks.
-type request_validation_error() :: {request_error, request_id(), graphql_error()}.
%% Errors in GraphQL execution which happened before the actual execution have started,
%% eg. during syntax / semantics / datatype validation.
%% Errors generated during GraphQL "field resolve" phase are returned as `{ok, #{errors => [..]}}'
-type auth_error() :: {auth_error, Msg :: unicode:chardata() | atom(), Extra :: json_object()}.
%% Authentication errors. Should only happen in `connect' and `init' callbacks.
-type protocol_error() ::
{protocol_error, Transport :: http | ws, Code :: atom(), Msg :: unicode:chardata(),
Extra :: json_object()}.
%% Errors in transport protocols (malformed HTTP / WS requests and packets).
%% Normally generated internally by protocol handlers, but may be produced by `connect' callback as
%% result of validation of `Req' parameter.
-type other_error() :: {other_error, Msg :: unicode:chardata(), Extra :: json_object()}.
%% Errors not matching any other category.
-type error_location() :: #{
line => pos_integer(),
column => pos_integer()
}.
-type graphql_error() :: #{
message := unicode:unicode_binary(),
locations => [error_location()],
path => [unicode:unicode_binary() | non_neg_integer()],
extensions => json_object()
}.
-type result() :: {
Id :: request_id(),
Done :: boolean(),
Data :: json_object() | undefined,
Errors :: [graphql_error()],
Extensions :: json_object()
}.
-type transport_info() ::
{ws, cowboy_graphql_ws_handler:features()}
| {http, cowboy_graphql_http_handler:features()}.
-callback connection(cowboy_req:req(), Params :: any()) ->
{ok, handler_state()}
| {error, auth_error() | protocol_error() | other_error()}.
%% Is called when the HTTP/WS connection is just happened, before body is fetched.
%% Not guaranteed to be executed from the same process as all the other callbacks.
%% Use it to create the initial state and maybe analyze some data from cowboy's request object,
%% perform authentication.
%% Don't use cowboy_req methods that modify the request (eg, don't read the body!).
%% Better not to start any timers / spawn processes / open resources / use process dict etc.
-callback init(
Params :: json_object(),
TransportInfo :: transport_info(),
State :: handler_state()
) ->
{ok, Params :: json_object(), handler_state()}
| {error, auth_error() | other_error(), handler_state()}.
%% Is called when `connection_init' is received via WebSocket or immediately after `connection/2'
%% when over HTTP.
-callback handle_request(
Id :: request_id(),
OperationName :: binary() | undefined,
Query :: unicode:chardata(),
Variables :: json_object(),
Extensions :: json_object(),
State :: handler_state()
) ->
{noreply, handler_state()}
| {reply, result() | [result()], handler_state()}
| {error, request_validation_error() | other_error(), handler_state()}.
%% Is called when GraphQL request (query / mutation / subscription) is received. Callback
%% supposed to execute the request and either return the response immediately OR run it in
%% background / initialize subscription and return `noreply'
-callback handle_cancel(Id :: binary(), handler_state()) ->
{noreply, handler_state()}
| {error, other_error(), handler_state()}.
%% Is called when operation is cancelled by the client. Callback is supposed to abort the execution
%% of the running query / mutation or stop the subscription identified by ID.
-callback handle_info(Msg :: any(), State :: handler_state()) ->
{noreply, handler_state()}
| {reply, result() | [result()], handler_state()}
| {error, other_error(), handler_state()}.
%% Is called when Erlang process, executing the request, received a regular Erlang message.
%% For example, payload from subscription channel.
-callback terminate(
Reason :: normal | atom() | tuple(),
State :: handler_state()
) ->
ok.
%% Is called when client have disconnected or about to disconnect, current erlang process is
%% about to terminate. Not guaranteed to be called!
%% Reason - see [[https://ninenines.eu/docs/en/cowboy/2.9/manual/cowboy_websocket/]]
%% =========================
%% APIs
%%
%% @doc Generate HTTP handler's cowboy_router "InitialState".
%%
%% Usage:
%% ```
%% Routes = [
%% ...,
%% {"/api/graphql/http",
%% cowboy_graphql_http_handler,
%% cowboy_graphql:http_config(my_graphql_handler, MyOpts, #{max_body_size => 5 * 1024 * 1024})},
%% ...
%% ]
%% '''
%%
%% See [[https://ninenines.eu/docs/en/cowboy/2.9/manual/cowboy_router/]]
-spec http_config(module(), any(), cowboy_graphql_http_handler:options()) ->
cowboy_graphql_http_handler:config().
http_config(Callback, CallbackOpts, TransportOpts) ->
cowboy_graphql_http_handler:config(Callback, CallbackOpts, TransportOpts).
%% @doc Generate WebSocket handler's cowboy_router "InitialState".
%%
%% Usage:
%% ```
%% Routes = [
%% ...,
%% {"/api/graphql/websocket",
%% cowboy_graphql_ws_handler,
%% cowboy_graphql:ws_config(my_graphql_handler, MyOpts, #{max_body_size => 5 * 1024 * 1024})},
%% ...
%% ]
%% '''
%%
%% See [[https://ninenines.eu/docs/en/cowboy/2.9/manual/cowboy_router/]]
-spec ws_config(module(), any(), cowboy_graphql_ws_handler:options()) ->
cowboy_graphql_ws_handler:config().
ws_config(Callback, CallbackOpts, TransportOpts) ->
cowboy_graphql_ws_handler:config(Callback, CallbackOpts, TransportOpts).
%% @doc Creates an `execution_error()' structure
-spec graphql_error(unicode:chardata(), [error_location()], map()) -> graphql_error().
graphql_error(Message, Locations, Extensions) when
is_list(Message) orelse is_binary(Message),
is_list(Locations),
is_map(Extensions)
->
Err0 = #{message => unicode:characters_to_binary(Message)},
Err1 =
case Locations of
[] ->
Err0;
_ ->
Err0#{
locations =>
lists:map(
fun(Loc) when is_map(Loc) ->
maps:with([line, column], Loc)
end,
Locations
)
}
end,
case map_size(Extensions) of
0 ->
Err1;
_ ->
Str = fun
(L) when is_list(L) -> unicode:characters_to_binary(L);
(A) when is_atom(A) -> atom_to_binary(A, utf8);
(B) when is_binary(B) -> B
end,
Err1#{
extensions =>
maps:from_list([{Str(K), V} || {K, V} <- maps:to_list(Extensions)])
}
end.
-spec request_validation_error(request_id(), graphql_error()) -> request_validation_error().
request_validation_error(Id, #{message := _} = Extra) ->
{request_error, Id, Extra}.
-spec auth_error(unicode:chardata(), map()) -> auth_error().
auth_error(Msg, Extra) ->
{auth_error, Msg, Extra}.
-spec other_error(unicode:chardata(), map()) -> other_error().
other_error(Msg, Extra) ->
{other_error, Msg, Extra}.
-spec protocol_error(ws | http, atom(), unicode:chardata(), map()) -> protocol_error().
protocol_error(Transport, Code, Msg, Extra) when
is_atom(Code),
Transport == ws orelse Transport == http
->
{protocol_error, Transport, Code, Msg, Extra}.
%%
%% Internal helpers
%%
%% @private
-spec put_not_empty(json_string(), json() | undefined, json_object()) -> json_object().
put_not_empty(Key, What, Where) ->
case is_empty(What) of
true ->
Where;
false ->
Where#{Key => What}
end.
is_empty(Map) when map_size(Map) =:= 0 ->
true;
is_empty([]) ->
true;
is_empty(undefined) ->
true;
is_empty(null) ->
true;
is_empty(_) ->
false.
%%
%% Behaviour helpers
%%
%% @private
-spec call_connection(Mod :: module(), cowboy_req:req(), Params :: any()) ->
{ok, handler_state()}
| {error, auth_error() | protocol_error() | other_error()}.
call_connection(Mod, Req, Params) ->
Mod:connection(Req, Params).
%% @private
-spec call_init(
Mod :: module(),
Params :: json_object(),
TransportInfo :: transport_info(),
State :: handler_state()
) ->
{ok, Params :: json_object(), handler_state()}
| {error, auth_error() | other_error(), handler_state()}.
call_init(Mod, Params, TransportInfo, State) ->
Mod:init(Params, TransportInfo, State).
%% @private
-spec call_handle_request(
Mod :: module(),
Id :: binary(),
OperationName :: binary() | undefined,
Query :: unicode:chardata(),
Variables :: json_object(),
Extensions :: json_object(),
State :: handler_state()
) ->
{noreply, handler_state()}
| {reply, result() | [result()], handler_state()}
| {error, request_validation_error() | other_error(), handler_state()}.
call_handle_request(Mod, Id, OperationName, Query, Variables, Extensions, State) ->
Mod:handle_request(Id, OperationName, Query, Variables, Extensions, State).
%% @private
-spec call_handle_cancel(Mod :: module(), Id :: binary(), handler_state()) ->
{noreply, handler_state()}
| {error, other_error(), handler_state()}.
call_handle_cancel(Mod, Id, State) ->
Mod:handle_cancel(Id, State).
%% @private
-spec call_handle_info(Mod :: module(), Msg :: any(), State :: handler_state()) ->
{noreply, handler_state()}
| {reply, result() | [result()], handler_state()}
| {error, other_error(), handler_state()}.
call_handle_info(Mod, Msg, State) ->
Mod:handle_info(Msg, State).
%% @private
-spec call_terminate(
Mod :: module(),
Reason :: normal | atom() | tuple(),
State :: handler_state()
) ->
ok.
call_terminate(Mod, Reason, State) ->
Mod:terminate(Reason, State).