When working with GraphQL queries and mutations, I want them to behave like a database: given an operation (ie upload a video), I want to look up the string I need to send, the variables the string needs and the response.
In SQL, this may be expressed like so:
select querystring, input_vars, output from graphql where action = 'upload_video';
Typeclasses in Haskell and PureScript are like database tables. Using typeclasses, we can execute the above query purely with types. Let's see how!
The GraphQL kind and the GraphQL class
Continuing the SQL analogy, a kind
is the "type" of your column or, in our program, the type of your type. There has been much ink spilled on comparing kinds to types of types, and it's not a perfect analogy, but in this case that's exactly what's going on.
In our example, each GraphQL query like upload_video
will be indexed by a data
type with kind GraphQL
. This index will point to three things - a type representing a string to send the server (called a Symbol
in PureScript), the type of the input and the type of the output.
foreign import kind GraphQL
class GraphQL (operation :: GraphQL) (gql :: Symbol) (i :: # Type) (o :: # Type) | operation -> gql i o
Let's see two different instances (rows) of our typeclass (table) GraphQL: one to get information from Filestack and one to upload a video to 8base.
foreign import data GetFileStackUploadInfo :: GraphQL
instance graphqlGetFileStackUploadInfo ::
GraphQL GetFileStackUploadInfo """
query {
fileUploadInfo {
policy
signature
apiKey
path
}
}
""" () ( fileUploadInfo ::
{ policy :: String
, signature :: String
, apiKey :: String
, path :: String
}
)
----
foreign import data VideoUpload :: GraphQL
instance graphqlVideoUpload ::
GraphQL VideoUpload """mutation ($id: ID!, $filename: String!, $fileId: String!) {
recordingUpdate(
filter: {
id: $id
}
data: {
video: {
create: {
filename: $filename
fileId: $fileId
}
}
}
) {
id
video {
id
downloadUrl
shareUrl
}
}
}
""" ( id :: String, filename :: String, fileId :: String ) ( recordingUpdate ::
{ id :: String
, video ::
{ id :: String
, downloadUrl :: String
, shareUrl :: String
}
}
)
Each instance (row) of GraphQL is filled with four types (columns) where each type has the correct kind (datatype). For example, GetFileStackUploadInfo
has the query (a multi-line string), the input variables type (in this case an empty record) and the output type (data for our file upload).
A word on symbols
Symbols are one of the more confusing concepts in PureScript because they look exactly like strings. This works from the compiler's point of view because strings are values whereas symbols are types, so it would never confuse a string for a symbol or vice versa. But the fact that they look alike can be confusing for us humans. What's more confusing is how something that looks like a string can be a type?
A string is just a list or array of characters with some sort of terminating "end-of-string" indicator. Similarly, a symbol is just a list of types with kind Char
and some sort of terminating indicator.
If we wanted to build symbols from the ground up in PureScript, we would do:
foreign import kind Char
foreign import data D :: Char
foreign import data O :: Char
foreign import data G :: Char
foreign import kind Word
foreign import data ConsW :: Char -> Word -> Word
foreign import data EndW :: Word
type DOG = (ConsW D (ConsW O (ConsW G EndW)))
Thankfully, the PureScript compiler reduces this boilerplate by allowing us to do:
type DOG = "DOG"
So Symbol
-s are just a list of characters with some terminal value, making each unique symbol a unique type.
Back to GraphQL
Now that I have my instances of GraphQL, I can create a function that sends the GraphQL to my backend (in this case, 8base).
data Gql (operation :: GraphQL)
= Gql
graphQL :: forall (operation :: GraphQL) (gql :: Symbol) (i :: # Type) (o :: # Type). GraphQL operation gql i o => IsSymbol gql => JSON.WriteForeign { | i } => JSON.ReadForeign { | o } => Gql operation -> Record i -> Aff { | o }
graphQL _ variables = do
endpoint <- liftEffect $ get8baseURL
token <- get8baseToken
let
output =
{ variables
, query: replaceAll (Pattern "\n") (Replacement " ") (replaceAll (Pattern "\r\n") (Replacement " ") (reflectSymbol (SProxy :: SProxy gql)))
}
res <-
AX.request
( AX.defaultRequest
{ url = endpoint
, method = Left POST
, responseFormat = ResponseFormat.string
, content =
Just
(RequestBody.string (JSON.writeJSON output))
, headers =
[ RequestHeader "Authorization" ("Bearer " <> token) ]
}
)
case res of
Left err -> do
liftEffect $ Log.info "Request did not go through"
throwError (error $ AX.printError err)
Right response -> case (JSON.readJSON response.body) of
Left err1 -> throwError (error $ ("Could not parse " <> show response.body <> " err " <> show err1))
Right ({ data: d } :: { data :: { | o } }) -> pure d
This function will work for any "row" from the GraphQL "table". Meaning we can do:
{ fileUploadInfo } <- graphQL (Gql :: Gql GetFileStackUploadInfo) {}
and we can also do:
{ recordingUpdate } <-
graphQL (Gql :: Gql VideoUpload)
{ id: input.recordingID
, filename: fsResponse.filename
, fileId
}
Both are typesafe: they will fail with anything other than the exact input you need and they will return nothing other than the exact input you expect to receive from your typeclass defintion.
An alternative to codegen
There are some great GraphQL codegen projects out there, and they definitely help reduce boilerplate. I personally prefer this approach because it pegs the output to the input. For example, checkout the codegen produced by graphql-codegen
for the following graphql:
scalar Date
schema {
query: Query
}
type Query {
me: User!
user(id: ID!): User
allUsers: [User]
search(term: String!): [SearchResult!]!
myChats: [Chat!]!
}
enum Role {
USER,
ADMIN,
}
interface Node {
id: ID!
}
union SearchResult = User | Chat | ChatMessage
type User implements Node {
id: ID!
username: String!
email: String!
role: Role!
}
type Chat implements Node {
id: ID!
users: [User!]!
messages: [ChatMessage!]!
}
type ChatMessage implements Node {
id: ID!
content: String!
time: Date!
user: User!
}
produces
export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
Date: any;
};
export type Query = {
__typename?: 'Query';
me: User;
user?: Maybe<User>;
allUsers?: Maybe<Array<Maybe<User>>>;
search: Array<SearchResult>;
myChats: Array<Chat>;
};
export type QueryUserArgs = {
id: Scalars['ID'];
};
export type QuerySearchArgs = {
term: Scalars['String'];
};
export enum Role {
User = 'USER',
Admin = 'ADMIN'
}
export type Node = {
id: Scalars['ID'];
};
export type SearchResult = User | Chat | ChatMessage;
export type User = Node & {
__typename?: 'User';
id: Scalars['ID'];
username: Scalars['String'];
email: Scalars['String'];
role: Role;
};
export type Chat = Node & {
__typename?: 'Chat';
id: Scalars['ID'];
users: Array<User>;
messages: Array<ChatMessage>;
};
export type ChatMessage = Node & {
__typename?: 'ChatMessage';
id: Scalars['ID'];
content: Scalars['String'];
time: Scalars['Date'];
user: User;
};
So if I query:
query { me { email } }
What do I get back? In the typeclass solution, I get back something with the type { me :: { email :: String } }
. In the typescript solution, I get back User
. This leads to three interrelated problems:
- I have a lot more boilerplate now to check if the fields I want (ie
email
) are present. - The object model is too permissive, which means I need to hand-write a type
{ email :: String }
to enforce email's presence downstream, which defeats the purpose of codegen. - If
email
is not served back for whatever reason, the runtime error is shifted far downstream to the point where we try to do something with it. In the typeclass solution, the failure happens directly at the point of query.
Note that this is no fault of graphql codegen (great project!). It's a limitation of typescript's type system and in general any system that does not have the ability to build up functional dependencies or dependent types.
Use typeclasses!
Typeclasses with functional dependencies are a graphql-programmer's dream-come-true. They allow you to create elegant simple relationships between graphql queries, input variables, and responses that are typesafe and predictable across your codebase. You can take typeclasses and functional dependencies for a spin in:
Top comments (0)