Skip to content
Antti Koskinen edited this page Oct 15, 2017 · 8 revisions

Anatomy of your app

Unlike REST, GraphQL is not concerned with HTTP methods or resource paths. Instead, you model your data as a graph and run queries against that using GraphQL query language. Despite the name, you don't need a special graph database. You can pull your data from any source, e.g. relational or NoSQL DB, or even another web service.

There are three main operation types:

  • query - read-only access to data, no side effects
  • mutation - alter data, has side effects
  • (subscription - async channel, not implemented yet)

Bulk of your application will consist of resolver functions and their specs. Resolvers are responsible for fetching data for your queries and modifying the graph with mutations. You can think of them as nodes in the graph. Resolvers are connected forming parent/child relationships.

Let's create a dummy blog application as an example. We'll start at the end and work our way backwards. Specialist-server.core/executor function takes a map as argument containing your application's root nodes:

(def my-executor (specialist-server.core/executor
                   {:query {:author #'author
                            :posts  #'posts
                            :post   #'post
                            ...
                           } 
                    :mutation {:post    #'new-post
                               :comment #'new-comment
                               ...
                              }}))

Note that resolvers are always passed around as vars.

Resolvers that don't depend on a parent node are put at the root level. Keys in the map are exposed as the entry points to the graph. Next, we'll add some specs:

;; Resolvers
(spec/def ::author   (specialist-server.type/resolver #'author))
(spec/def ::comments (specialist-server.type/resolver #'comments))

;; post return value definition
(spec/def ::post-node (spec/keys :req-un [::id ::date ::title ::text ::author ::comments]))

;; Author resolver. This can be called from the root of the graph as well as inside a post resolver.
(spec/fdef author
        :args (spec/tuple (spec/or :post ::post-node :root map?) 
                          (spec/keys :opt-un [::id])
                          map?
                          map?)
        :ret (spec/keys :req-un [::id ::name ::email]))

(spec/fdef comments
        :args (spec/tuple ::post-node map? map? map?)
        :ret (spec/* (spec/keys :req-un [::id ::date ::text ::email])))

(spec/fdef posts
        :args (spec/tuple map? map? map? map?)
        :ret (spec/* ::post-node))

(spec/fdef post
        :args (spec/tuple map? (spec/keys :req-un [::id]) map? map?)
        :ret ::post-node)

(Resolver function signatures are described in the chapter below.)

Post and comments implementations could look something like this:

(defn comments
  "Fetch all comments for a post"
  [node opt ctx info]
  (let [post-id (:id node)]
    (get-all-comments ctx post-id)))
    
(defn posts
  "Fetch all posts"
  [node opt ctx info]
  (let [with-child-nodes (fn [p]
                           (assoc p 
                                  :author   #'author
                                  :comments #'comments))]
    (map with-child-nodes (get-all-posts ctx))))

This starts to look like a graph! Author and comments resolvers are attached as a child node to every post. These are passed along as vars and automatically called when needed. Node argument in comments function will always be the return value of the parent post node and related comments can be fetched with the id key.

Now we could test our app with a query like this:

query {
  posts {
    title
    text
    date
    author {
      name
    }
    comments {
      date
      text
      email
    }
  }
}

Running queries

You'll probably want your graph to be available to the outside world. The specification doesn't say anything about transport mechanisms but a common way is to respond to JSON POST requests on a single url endpoint. We can easily accomplish that with Compojure:

(POST "/graphql" req
      (let [opt {:query     (get-in req [:body :query])
                 :variables (get-in req [:body :variables])
                 :root      (my-query-root)
                 :context   (my-query-context)}]
        (response (my-executor opt))))

(We assume this handler is wrapped in JSON middlewares for encoding and decoding.)

My-executor function here is returned from specialist-server.core/executor. It takes a single map as argument with following keys:

  • :query - GraphQL query string we want to execute
  • :variables - A map of variable values used in the query. Defaults to empty map.
  • :root - A map passed to all top-level resolvers as root node. This can be used for e.g. default values. Defaults to empty map.
  • :context - Request-specific data map. This is passed to every resolver function and can be used for e.g. user sessions, DB connection, config values or other dependency injection. Defaults to empty map.

If the query resolves successfully, my-executor returns the result as a map:

{:data { ... }}

Resolver functions

Your basic resolver function looks like this:

(defn my-resolver
  "My type description goes here."
  [node opt ctx info]
  ...)

Every resolver function is called with four positional arguments. Therefore, the use of spec/tuple is recommended in spec/fdef :args. These arguments are all maps and they have the following meanings and conventional names:

  • node - The map that contains the parent resolver's result, or, in the case of a top-level resolver, the :root passed to your executor function. This argument enables the nested nature of GraphQL queries.

  • opt - A map with the arguments passed into this field in the query. For example, if the field was called with author(id: 123), the opt map would be: {:id 123}.

  • ctx: This is the map defined as value for :context key in your executor arguments. It is passed to all resolvers in a particular query, and is used to contain per-request state, including authentication information, DB connection, and anything else that should be taken into account when resolving the query.

  • info: Info map is mostly for internal use but it is exposed here for debugging purposes and special use-cases. It contains information about the execution state of the query. Currently undocumented on purpose.

Every resolver function must have a valid fspec with :args and :ret keys.

Type introspection

Clone this wiki locally