Version 12 of GraphQL

Updated 2017-10-09 23:36:30 by Napier

GraphQL with Tcl

This is not currently an attempt to build a fully compliant GraphQL package, but I did want to be a parse the GraphQL general syntax and use it to unify the syntax that I use to communicate between various parts of my application. I figured others may find this useful to use or extend. I built this without reading the spec closely so it should be considered when using this code. I will likely improve it over time, and if it ends up being used a lot in our production app you can expect it will grow out from here.

GraphQL can be an awesome way of querying and controlling various aspects and can be considered a replacement of the "REST" protocol.


GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.


Current Support

  • Directives
  • Query/Mutations
  • Variables and simple types including require (!)
  • Query/Mutation Names
  • call fn as property name (projOne: project() {})

Example GraphQL Query

GraphQL allows us to make a query and request the exact data we want. Our server can then respond with only that data without all the extra values that normally might be included with a rest call. When this starts to get more and more complex you quickly realize it can save an amazing amount of REST calls by turning them all into one.

query MyGraphQLQuery(
  $projectIdentityID: String!
  $size: Int!
  $includeColor: Boolean
  $skipBanner: Boolean
) {
  project(projectIdentityID: $projectIdentityID) {
    events(
      size: $size
    ) {
      timestamp
      event {
        from
        banner @skip(if: $skipBanner)
        title
        message
        priority
        icon
        color @include(if: $includeColor)
      }
    }
  }
}

Currently this will parse the syntax and return a simple data structure to parse through so that the query/mutation can be handled properly. We may receive a JSON query that looks like this:

{
    "query":     {
        "query": "$THE_QUERY_ABOVE"
    },
    "variables": {
        "includeColor":      false,
        "skipBanner":        true,
        "projectIdentityID": "PROJECT",
        "size":              10
    }
}

Note: This structure is the way graphql compliant servers will generally expect the data to be formatted. This is inline with GraphiQL programs method of parsing.

In this case, the data returned would be:

type query 
name MyGraphQLQuery 
variables {
  includeColor 0 
  skipBanner 1 
  projectIdentityID PROJECT 
  size 10
} 
definitions {
  projectIdentityID {type string required true} 
  size {type integer required true} 
  includeColor {type boolean required false} 
  skipBanner {type boolean required false}
} 
requests {
  project {
    name project 
    fn project
    args {
      projectIdentityID PROJECT
    } 
    props {
      {
        name events 
        fn events 
        args {
          size 10 
        } 
        props {
          { name timestamp } 
          { 
            name event 
            fn event 
            props {
              {name from} 
              {name title} 
              {name message} 
              {name priority} 
              {name icon}
            }
          }
        }
      }
    }
  }
}

Walk the Result

Walking the result of a parsed query is really quite simple. At any level we may invoke functions that should inherit the properties of the function it is called within, if the value is a function call, it will have the "fn" key and the "props" key. Directives are removed automatically and will not be included in this structure unless the directive did not pass.

Below is a little helper to show how you may parse a request then walk it, in this case printing out the resulting request.

proc printProp prop {
  upvar 1 lvl plvl
  set lvl [expr { $plvl + 1 }]
  set prefix [string repeat " " $lvl]
  puts "$prefix -- PROP -- [dict get $prop name]"
  if {[dict exists $prop args]} {
    puts "$prefix Args: [dict get $prop args]"
  }
  if {[dict exists $prop fnargs]} {
    puts "$prefix FN Args: [dict get $prop fnargs]"
  }
  if {[dict exists $prop props]} {
    puts "$prefix - Total Props [llength [dict get $prop props]]"
    foreach cprop [dict get $prop props] {
      printProp $cprop
    }
  }
}

proc print {} {
  set lvl 0
  dict for {k v} $::result {
    switch -- $k {
      requests {
        dict for {query schema} $v {
          puts "
            --- QUERY $query ---
          "

          printProp $schema
        }
      }
      default {
        puts "-$k -> $v"
      }
    }
  }
}

proc parse {} {
  set ::result [::graphql::parse $::PACKET]
  print
}

With the query that we show at the start:

Type query
projectIdentityID PROJECT
-variables -> includeColor 0 skipBanner 1 projectIdentityID PROJECT size 10
-type -> query
-name -> MyGraphQLQuery
-definitions -> projectIdentityID {type string required true} size {type integer required true} includeColor {type boolean required false} skipBanner {type boolean required false}

            --- QUERY project ---

  -- PROP -- project
  Args: projectIdentityID PROJECT
  - Total Props 1
   -- PROP -- events
   Args: size 10 proj PROJECT
   - Total Props 2
    -- PROP -- timestamp
    -- PROP -- event
    - Total Props 5
     -- PROP -- from
     -- PROP -- title
     -- PROP -- message
     -- PROP -- priority
     -- PROP -- icon

The Code

This will likely be uploaded to the tcl-modules repo shortly at which point it will become the place for the most recent revisions of the script below. Although I will try to keep it up-to-date.

namespace eval graphql {}

namespace eval ::graphql::regexp {
  variable graphql_re {(?xi) # this is the _expanded_ syntax
    ^\s*
    (?:  # Capture the first value (query|mutation) -> $type
         # if this value is not defined, query is expected.
      ([^\s\(\{]*)
      (?:(?!\()\s*)?
    )?
    (?:          # A query/mutation may optionally define a name
      (?!\()
      ([^\s\(\{]*)
    )?
    (?:\(([^\)]*)\))? # Capture the values within the variables scope
    \s*
    (?:{\s*(.*)\s*})
    $
  }

  # The start of the parsing, we capture the next value in the body
  # which starts the process of capturing downwards.
  variable graphql_body_re {(?xi)
    ^\s*
    (?!\})
    ([^\s\(\{:]*)  # capture the name of the query
    (?:            # optionally may define a name and fn mapping
      :            # if a colon, then we need the fn name next
      \s*
      ([^\s\(\{]*) # capture the name of the fn
    )?
    \s*
    (?:\(([^\)]*)\))? # optionally capture var definitions
    \s*
    (.*)  # grab the rest of the request
    $
  }

  variable graphql_next_query_re {(?xi)
    ^\s*(?:\{\s*)?
    (?!\})
    ([^\s\(\{:]*)  # capture the name of the query
    (?:        # optionally may define a name and fn mapping
      (?=\s*:)
      \s*:         # if a colon, then we need the fn name next
      \s*
      ([^\s\(\{]*) # capture the name of the fn
    )?
    (?:
      (?=\s*\()    # check if we have arg declarations
      \s*
      \(
        ([^\)]*)   # grab the arg values
      \)
    )?
    (?:           # directives @include(if: $boolean) / @skip(if: $boolean)
      (?=\s*@)
      \s*(@[^\(]*\([^\)]*\)) # capture the full directive to be parsed
                             # we only capture the full directive and
                             # run another query to get the fragments
                             # if needed.
    )?
    (?:
      (?=\s*\{)    # this is a object type, capture the rest so we know
                   # to continue parsing
      \s*\{
      (.*)
      $
    )?
    \s*           # this will only have a value if we are done parsing
    (.*)          # this value, otherwise its sibling will.
  }

  variable graphql_directive_re {(?xi)
    ^@
    ([^\s\(]*)  # the directive type - currently "include" or "skip"
    \s*\(if:\s*
    ([^\s\)]*)  # capture the variable to check against
    \s*\)
    $
  }
}

namespace eval ::graphql::parse {}

proc ::graphql::parse packet {
  set data [json get $packet]
  set parsed [dict create]
  set type {}
  set definitions {}

  if {[dict exists $data variables]} {
    dict set parsed variables [dict get $data variables]
  }

  if {[dict exists $data query query]} {
    set query [string trim [dict get $data query query]]
  }

  regexp -- $regexp::graphql_re $query \
    -> type name definitions body

  puts "Type $type"

  dict set parsed type $type

  dict set parsed name $name

  set body [string trim $body]

  if {$definitions ne {}} {
    ::graphql::parse::definitions $definitions
  }

  ::graphql::parse::body $body

  return $parsed
}

proc ::graphql::parse::definitions definitions {
  upvar 1 parsed parsed
  foreach {var type} $definitions {
    set var [string trimright $var :]
    set var [string trimleft $var \$]
    if {[string match "*!" $type]} {
      set type [string trimright $type !]
      set required true
      if {![dict exists $parsed variables $var]} {
        tailcall return \
          -code error \
          -errorCode [list GRAPHQL PARSE VAR_NOT_DEFINED] \
          " variable $var is required but it was not provided within the request"
      }
    } else {
      set required false
    }
    if {[string index $type 0] eq "\["} {
      set isArray true
      set type [string range $type 1 end-1]
    } else {
      set isArray false
    }
    if {[dict exists $parsed variables $var]} {
      set varValue [dict get $parsed variables $var]
    }
    set type [string tolower $type]
    switch -- $type {
      float {
        set type double
        set checkType true
      }
      boolean {
        set checkType true
      }
      int {
        set type integer
        set checkType true
      }
      default {
        set checkType false
      }
    }
    if {$checkType && [info exists varValue]} {
      if {$isArray} {
        set i 0
        foreach elval $varValue {
          if {![string is $type -strict $elval]} {
            tailcall return \
              -code error \
              -errorCode [list GRAPHQL PARSE VAR_INVALID_TYPE IN_ARRAY] \
              " variable $var element $i should be ${type} but received: \"$elval\" while checking \"Array<${varValue}>\""
          }
          incr i
        }
      } elseif {![string is $type -strict $varValue]} {

      }
    }
    dict set parsed definitions $var [dict create \
      type     [string tolower $type] \
      required $required
    ]
  }
}

proc ::graphql::parse::arg arg {
  upvar 1 parsed parsed
  if {[dict exists $parsed variables]} {
    set variables [dict get $parsed variables]
  }
  if {[string index $arg 0] eq "\$"} {
    set name [string range $arg 1 end]
    if {[dict exists $variables $name]} {
      set arg [dict get $variables $name]
    } else {
      return -code error " variable $name not found for arg $arg"
    }
  }
  return $arg
}

proc ::graphql::parse::fnargs fnargs {
  upvar 1 parsed parsed
  set data [dict create]

  # set argName  {}
  # set argValue {}
  foreach arg $fnargs {
    set arg [string trim $arg]
    if {$arg eq ":"} {
      continue
    }
    if {[info exists argValue]} {
      # Once defined, we can set the value and unset our vars
      dict set data [arg $argName] [arg $argValue]
      unset argName
      unset argValue
    }
    if {![info exists argName]} {
      set colonIdx [string first : $arg]
      if {$colonIdx != -1} {
        if {[string index $arg end] eq ":"} {
          set argName [string trimright $arg :]
        } else {
          lassign [split $arg :] argName argValue
        }
      } else {
        # this is probably not right?
        set argName $arg
      }
    } else {
      set argValue $arg
    }
  }

  if {[info exists argName] && [info exists argValue]} {
    dict set data [arg $argName] [arg $argValue]
  }

  return $data
}

proc ::graphql::parse::directive directive {
  upvar 1 parsed parsed

  if {[dict exists $parsed variables]} {
    set variables [dict get $parsed variables]
  } else {
    set variables [dict create]
  }

  regexp -- $::graphql::regexp::graphql_directive_re $directive \
    -> type var

  if {[string index $var 0] eq "\$"} {
    set name [string range $var 1 end]
    if {[dict exists $variables $name]} {
      set val [dict get $variables $name]
    }
  } else {
    set val $var
  }

  switch -nocase -- $type {
    include {
      if {![info exists val] || ![string is true -strict $val]} {
        return false
      }
    }
    skip {
      if {[info exists val] && [string is true -strict $val]} {
        return false
      }
    }
    default {
      return tailcall \
        -code error \
        -errorCode [list GRAPHQL BAD_DIRECTIVE] \
        " provided a directive of type $type ($directive).  This is not supported by the GraphQL Syntax."
    }
  }

  return true

}

proc ::graphql::parse::body remaining {
  upvar 1 parsed parsed
  set lvl 1
  set props [list]

  while {$remaining ne {}} {
    regexp -- $::graphql::regexp::graphql_body_re $remaining \
      -> name fn fnargs remaining

    if {$fn eq {}} {
      set fn $name
    }

    if {$fnargs ne {}} {
      set fnargs [::graphql::parse::fnargs $fnargs]
      puts $fnargs
    }

    set remaining [nextType $remaining]

    dict set parsed requests $name [dict create \
      name  $name \
      fn    $fn \
      args  $fnargs \
      props $props
    ]

    set remaining [string trimleft $remaining { \} }]
  }
}

proc ::graphql::parse::nextType remaining {
  upvar 1 props pprops
  upvar 1 lvl plvl
  upvar 1 parsed parsed
  set lvl [expr {$plvl + 1}]

  while {[string index $remaining 0] ne "\}" && $remaining ne {}} {
    unset -nocomplain name
    set skip false
    set props [list]

    regexp -- $::graphql::regexp::graphql_next_query_re $remaining \
      -> name fn fnargs directive schema remaining

    if {![info exists name] || $name eq {}} {
      break
    }

    if {$directive ne {}} {
      # directive will tell us whether or not we should be
      # including the value.
      if {![::graphql::parse::directive $directive]} {
        set skip true
      }
    }

    set prop [dict create \
      name $name
    ]

    if {[info exists fnargs] && $fnargs ne {}} {
      set fnargs [::graphql::parse::fnargs $fnargs]
      dict set prop args $fnargs
    }

    if {[info exists schema]} {
      set schema [string trim $schema]
      if {$schema ne {}} {
        if {$fn eq {}} {
          set fn $name
        }
        dict set prop fn $fn
        set schema [nextType $schema]
        set schema [string trim $schema]
        set remaining [string trimleft $schema \}]
      }
    }

    if {[string is false $skip]} {
      if {[llength $props] > 0} {
        dict set prop props $props
      }

      lappend pprops $prop
    }

    set remaining [string trimleft $remaining { \n }]
  }

  set remaining [string trimleft $remaining { \}\n }]

  # At this point, $schema will have content if we need to continue
  # parsing this type, otherwise it will be within remaining
  return $remaining
}