graphql

GraphQL server library for Crystal

Logo

GraphQL server library for Crystal.

  • Boilerplate-free: Schema generated at compile time
  • Type-safe: Crystal guarantees your code matches your schema
  • High performance: See benchmarks

Getting Started

Install the shard by adding the following to our shard.yml:

dependencies:
  graphql:
    github: graphql-crystal/graphql

Then run shards install.

The first step is to define a query object. This is the root type for all queries and it looks like this:

require "graphql"

@[GraphQL::Object]
class Query < GraphQL::BaseQuery
  @[GraphQL::Field]
  def hello(name : String) : String
    "Hello, #{name}!"
  end
end

Now we can create a schema object:

schema = GraphQL::Schema.new(Query.new)

To verify we did everything correctly, we can print out the schema:

puts schema.document.to_s

Which, among several built-in types, prints our query type:

type Query {
  hello(name: String!): String!
}

To serve our API over HTTP we call schema.execute with the request parameters and receive a JSON string. Here is an example for Kemal:

post "/graphql" do |env|
  env.response.content_type = "application/json"

  query = env.params.json["query"].as(String)
  variables = env.params.json["variables"]?.as(Hash(String, JSON::Any)?)
  operation_name = env.params.json["operationName"]?.as(String?)

  schema.execute(query, variables, operation_name)
end

Now we're ready to query our API:

curl \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{ "query": "{ hello(name: \"John Doe\") }" }' \
  http://0.0.0.0:3000/graphql

This should return:

{ "data": { "hello": "Hello, John Doe!" } }

For easier development, we recommend using GraphiQL. A starter template combining Kemal and GraphiQL is found at examples/graphiql.

Context

context is a optional argument that our fields can retrieve. It lets fields access global data, like database connections.

# Define our own context type
class MyContext < GraphQL::Context
  @pi : Float64
  def initialize(@pi)
  end
end

# Pass it to schema.execute
context = MyContext.new(Math::PI)
schema.execute(query, variables, operation_name, context)

# Access it in our fields
@[GraphQL::Object]
class MyMath < GraphQL::BaseObject
  @[GraphQL::Field]
  def pi(context : MyContext) : Float64
    context.pi
  end
end

Context instances must not be reused for multiple executions. A subscription is one execution: the context passed to schema.subscribe lives for the whole subscription and every event is resolved with it.

Concurrency

By default, a query is resolved sequentially in the fiber that called schema.execute. To resolve fields and array elements concurrently, set max_concurrency on the context:

context = MyContext.new(Math::PI)
context.max_concurrency = 8
schema.execute(query, variables, operation_name, context)

This is the maximum number of fibers a single execution may have running at once. When the budget is used up, remaining work runs inline, so a large list never fans out into an unbounded number of fibers. Choose a value your downstream resources can sustain; resolvers that check out database connections should stay below the connection pool size.

Complexity

To reject operations that select too many fields, set max_complexity on the context:

context = MyContext.new(Math::PI)
context.max_complexity = 200
schema.execute(query, variables, operation_name, context)

Complexity is the number of fields the operation selects, counted across the whole selection tree with fragments expanded. It is computed before any resolver runs, and an operation over the limit is answered with an error and no data. After execution, context.complexity holds the count.

Nesting depth is limited separately. Selection sets, lists and input objects may nest max_depth levels, 100 by default, and a deeper query is rejected while parsing. Real queries rarely pass twenty levels; the limit exists so a hostile query cannot exhaust the stack:

context.max_depth = 30

Benchmarking

bench/bench.cr runs realistic requests against a schema with an interface, a union, an enum, an input object and nesting, and reports throughput per scenario. Run it before and after a change, on the same machine, and compare:

crystal run --release bench/bench.cr -- --json /tmp/before.json
# make your changes
crystal run --release bench/bench.cr -- --compare /tmp/before.json

--only NAME runs one scenario and --seconds N changes the time budget per scenario. Every response is checked for errors before timing starts.

Objects

Objects are perhaps the most commonly used type in GraphQL. They are implemented as classes. To define a object, we need a GraphQL::Object annotation and to inherit GraphQL::BaseObject. Fields are methods with a GraphQL::Field annotation.

@[GraphQL::Object]
class Foo < GraphQL::BaseObject
  # type restrictions are mandatory on fields
  @[GraphQL::Field]
  def hello(first_name : String, last_name : String) : String
    "Hello #{first_name} #{last_name}"
  end

  # besides basic types, we can also return other objects
  @[GraphQL::Field]
  def bar : Bar
    Bar.new
  end
end

@[GraphQL::Object]
class Bar < GraphQL::BaseObject
  @[GraphQL::Field]
  def baz : Float64
    42_f64
  end
end

For simple objects, we can use instance variables:

@[GraphQL::Object]
class Foo < GraphQL::BaseObject
  @[GraphQL::Field]
  property bar : String

  @[GraphQL::Field]
  getter baz : Float64

  def initialize(@bar, @baz)
  end
end

Query

Query is the root type of all queries.

@[GraphQL::Object]
class Query < GraphQL::BaseQuery
  @[GraphQL::Field]
  def echo(str : String) : String
    str
  end
end

schema = GraphQL::Schema.new(Query.new)

Mutation

Mutation is the root type for all mutations.

@[GraphQL::Object]
class Mutation < GraphQL::BaseMutation
  @[GraphQL::Field]
  def echo(str : String) : String
    str
  end
end

schema = GraphQL::Schema.new(Query.new, Mutation.new)

Input Objects

Input objects are objects that are used as field arguments. To define an input object, use a GraphQL::InputObject annotation and inherit GraphQL::BaseInputObject. It must define a constructor with a GraphQL::Field annotation.

@[GraphQL::InputObject]
class User < GraphQL::BaseInputObject
  getter first_name : String?
  getter last_name : String?

  @[GraphQL::Field]
  def initialize(@first_name : String?, @last_name : String?)
  end
end

Enums

Defining enums is straightforward. Just add a GraphQL::Enum annotation:

@[GraphQL::Enum]
enum IPAddressType
  IPv4
  IPv6
end

Scalars

The following scalar values are supported:

  • Int32 <-> Int
  • Float64 <-> Float
  • String <-> String
  • Bool <-> Boolean
  • GraphQL::Scalars::ID <-> String

Built-in custom scalars:

  • GraphQL::Scalars::BigInt <-> String

Custom scalars are created by implementing from_json/to_json:

@[GraphQL::Scalar]
class ReverseStringScalar < GraphQL::BaseScalar
  @value : String

  def initialize(@value)
  end

  def self.from_json(string_or_io)
    self.new(String.from_json(string_or_io).reverse)
  end

  def to_json(builder : JSON::Builder)
    builder.scalar(@value.reverse)
  end
end

A custom scalar's to_json is rendered into a buffer, so an exception in it only affects that field. The library's built-in scalars are written straight into the response instead; if you reopen one of them and its to_json raises, the enclosing object becomes null and the error is reported there.

Interfaces

An interface is an abstract class or a module with a GraphQL::Interface annotation. Its fields are GraphQL::Field methods, abstract or not. Objects that inherit from the class or include the module implement the interface, and fields may return the interface type:

@[GraphQL::Interface]
abstract class Character < GraphQL::BaseObject
  @[GraphQL::Field]
  abstract def name : String
end

@[GraphQL::Object]
class Human < Character
  @[GraphQL::Field]
  def name : String
    "Luke"
  end

  @[GraphQL::Field]
  def home_planet : String
    "Tatooine"
  end
end

@[GraphQL::Object]
class Query < GraphQL::BaseQuery
  @[GraphQL::Field]
  def hero : Character
    Human.new
  end
end

Every object implementing an interface is part of the schema as soon as the interface is. Fields specific to one implementation are selected through fragments with a type condition, and __typename reports the concrete type:

{
  hero {
    __typename
    name
    ... on Human {
      homePlanet
    }
  }
}

Unions

A union is a module with a GraphQL::Union annotation. Objects that include the module are its member types. A union has no fields of its own, so selections on it must go through type conditions:

@[GraphQL::Union]
module SearchResult
end

@[GraphQL::Object]
class Human < GraphQL::BaseObject
  include SearchResult
  # ...
end

@[GraphQL::Object]
class Starship < GraphQL::BaseObject
  include SearchResult
  # ...
end

@[GraphQL::Object]
class Query < GraphQL::BaseQuery
  @[GraphQL::Field]
  def search(text : String) : Array(SearchResult)
    [Human.new, Starship.new] of SearchResult
  end
end
{
  search(text: "a") {
    __typename
    ... on Human { name }
    ... on Starship { length }
  }
}

Both annotations accept name and description like GraphQL::Object.

Subscriptions

A subscription type inherits GraphQL::BaseSubscription. Its fields return a Channel whose element type is the field's GraphQL type, and every value sent on the channel becomes one response. GraphQL::Broadcast fans values out to any number of subscribers, which is the usual way to publish from a mutation:

MESSAGES = GraphQL::Broadcast(Message).new

@[GraphQL::Object]
class Mutation < GraphQL::BaseMutation
  @[GraphQL::Field]
  def post(text : String) : Message
    message = Message.new(text)
    MESSAGES.publish(message)
    message
  end
end

@[GraphQL::Object]
class Subscription < GraphQL::BaseSubscription
  @[GraphQL::Field]
  def message_added : Channel(Message)
    MESSAGES.subscribe
  end
end

schema = GraphQL::Schema.new(Query.new, Mutation.new, Subscription.new)

schema.subscribe takes the same arguments as schema.execute and returns a GraphQL::Subscription that yields response documents. It ends when the resolver's channel closes. Call close to unsubscribe, which also closes the resolver's channel:

subscription = schema.subscribe(%(subscription { messageAdded { text } }))
spawn do
  subscription.each do |response|
    puts response # {"data":{"messageAdded":{"text":"hi"}}}
  end
end

A request that cannot be started, for example because it selects two root fields, yields a single error response and is already closed.

Over WebSockets

GraphQL::Transport::WebSocket speaks the graphql-transport-ws protocol used by graphql-ws, Apollo Client, urql and Relay. It is not loaded by require "graphql". With Kemal:

require "graphql/transport/ws"

ws "/graphql" do |socket, env|
  GraphQL::Transport::WebSocket.new(schema, socket) { MyContext.new(env) }
end

With the standard library's HTTP::Server, pass the protocol name so the handshake negotiates it, which browsers require. The subprotocol argument needs Crystal 1.20 or later:

HTTP::WebSocketHandler.new([GraphQL::Transport::WebSocket::PROTOCOL]) do |socket, http|
  GraphQL::Transport::WebSocket.new(schema, socket) { MyContext.new(http.request) }
end

The block builds the context for every operation on the connection. Queries and mutations sent over the socket are executed once.

Annotation Arguments

name

Supported on: Object, InputObject, Field, Enum, Scalar

We can use the name argument to customize the introspection type name of a type. This is not needed in most situations because type names are automatically converted to PascalCase or camelCase. However, item_id converts to itemId, but we might want to use itemID. For this, we can use the name argument.

@[GraphQL::Object(name: "Greeter")]
class GreetingService
  @[GraphQL::Field(name: "hello")]
  def say_hello : String
    "Hello"
  end
end

description

Supported on: Object, InputObject, Field, Enum, Scalar

Describes the type. Descriptions are available through the introspection interface so it's always a good idea to set this argument.

@[GraphQL::Object(description: "Produces greetings in several languages")]
class Greeter
end

deprecated

Supported on: Field

The deprecated argument marks a type as deprecated.

class Greeter
  @[GraphQL::Field(deprecated: "Use greet(lang:) instead")]
  def hello : String
    "Hello"
  end
end

values

On enums, values describes or deprecates members by constant name:

@[GraphQL::Enum(values: {
  Red:  {description: "Like a rose"},
  Blue: {deprecated: "Use Navy"},
})]
enum Color
  Red
  Blue
  Navy
end

specified_by_url

On scalars, specified_by_url points at the specification of the scalar's format and is reported as specifiedByURL in introspection:

@[GraphQL::Scalar(specified_by_url: "https://tools.ietf.org/html/rfc3339")]
record DateTime, value : Time do
  # ...
end

arguments

Sets names, descriptions and deprecations for field arguments. Each argument may set name, description and deprecated:

class Greeter
  @[GraphQL::Field(arguments: {
    language: {name: "lang", description: "Language code of the greeting"},
    formal:   {deprecated: "Greetings are always informal now"},
  })]
  def greet(language : String, formal : Bool? = nil) : String
    case language
    when "fr" then "Bonjour"
    when "de" then "Hallo"
    else           "Hello"
    end
  end
end

Field Arguments

Field arguments are automatically resolved. A type with a default value becomes optional. A nilable type is also considered a optional type.

Repository

graphql

Owner
Statistic
  • 139
  • 15
  • 0
  • 6
  • 2
  • 5 days ago
  • July 5, 2020
License

MIT License

Links
Synced at

Fri, 09 Oct 2026 18:48:35 GMT

Languages