├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── docs.yaml │ └── test.yaml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── icon.svg └── logo.svg ├── examples └── graphiql │ ├── .gitignore │ ├── index.html │ ├── main.cr │ └── shard.yml ├── renovate.json ├── shard.yml ├── spec ├── array_spec.cr ├── base_classes_spec.cr ├── bigint_spec.cr ├── context_spec.cr ├── enums │ ├── as_argument_array_spec.cr │ ├── as_argument_default_spec.cr │ ├── as_argument_optional_spec.cr │ ├── as_argument_spec.cr │ ├── as_input_argument_spec.cr │ └── as_return_spec.cr ├── exception_spec.cr ├── fixtures │ ├── base_classes.cr │ ├── empty_query.cr │ ├── mutation.cr │ ├── mutation.graphql │ ├── query.cr │ ├── query.graphql │ ├── query_introspection.json │ ├── star_wars.cr │ └── star_wars.graphql ├── instance_var_spec.cr ├── language_spec.cr ├── mutation_spec.cr ├── query_spec.cr ├── schema_spec.cr ├── spec_helper.cr └── star_wars_spec.cr └── src ├── graphql.cr └── graphql ├── annotations.cr ├── context.cr ├── document.cr ├── error.cr ├── input_object_type.cr ├── internal └── convert_value.cr ├── introspection.cr ├── introspection_query.cr ├── language.cr ├── language ├── ast.cr ├── generation.cr ├── lexer.cr ├── lexer_context.cr ├── nodes.cr ├── parser.cr ├── parser_context.cr └── token.cr ├── mutation_type.cr ├── object_type.cr ├── query_type.cr ├── scalar_type.cr ├── scalars.cr └── schema.cr /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT="bullseye" 2 | FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} 3 | 4 | RUN sudo apt update && apt install build-essential -y \ 5 | && curl -fsSL https://crystal-lang.org/install.sh | bash \ 6 | && wget https://github.com/elbywan/crystalline/releases/latest/download/crystalline_x86_64-unknown-linux-gnu.gz -O crystalline.gz \ 7 | && gzip -d crystalline.gz && chmod +x crystalline && mv crystalline /usr/local/bin/crystalline -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.155.1/containers/debian 3 | { 4 | "name": "Debian", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick an Debian version: buster, stretch 8 | "args": { "VARIANT": "bullseye" } 9 | }, 10 | 11 | // Set *default* container specific settings.json values on container create. 12 | "settings": { 13 | "terminal.integrated.shell.linux": "/bin/bash" 14 | }, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "crystal-lang-tools.crystal-lang", 19 | "esbenp.prettier-vscode", 20 | "tombonnike.vscode-status-bar-format-toggle" 21 | ], 22 | 23 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 24 | // "forwardPorts": [], 25 | 26 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. 27 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], 28 | 29 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 30 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 31 | 32 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "vscode" 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: crystallang/crystal:latest-alpine 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | - name: Install rysync 18 | run: apk add rsync 19 | - name: Build documentation 20 | run: crystal docs 21 | - name: Deploy to GitHub Pages 22 | uses: JamesIves/github-pages-deploy-action@v4.7.3 23 | with: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | BRANCH: gh-pages 26 | FOLDER: docs 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 18 * * *" 8 | 9 | jobs: 10 | spec: 11 | runs-on: ubuntu-latest 12 | container: 13 | image: crystallang/crystal:latest-alpine 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Install dependencies 18 | run: shards install 19 | - name: Run tests 20 | run: crystal spec 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /bin 3 | /docs 4 | shard.lock -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "crystal-lang.server": "/usr/local/bin/crystalline", 3 | "crystal-lang.hover": true, 4 | "crystal-lang.completion": true 5 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.4.0] - 2022-03-29 11 | 12 | ### Added 13 | 14 | - Base classes 15 | - Custom exception handler on `Context` 16 | - BigInt scalar 17 | - Instance vars support 18 | 19 | ### Fixed 20 | 21 | - Fixed enums in input objects 22 | - Arrays can now be nested 23 | - Array members are now marked as non-null unless nilable 24 | 25 | ### Changed 26 | 27 | - Removed implicit Int64 conversion 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jakob Gillich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](assets/logo.svg) 2 | 3 | GraphQL server library for Crystal. 4 | 5 | - **Boilerplate-free**: Schema generated at compile time 6 | - **Type-safe**: Crystal guarantees your code matches your schema 7 | - **High performance**: See [benchmarks](https://github.com/graphql-crystal/benchmarks) 8 | 9 | ## Getting Started 10 | 11 | Install the shard by adding the following to our `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | graphql: 16 | github: graphql-crystal/graphql 17 | ``` 18 | 19 | Then run `shards install`. 20 | 21 | The first step is to define a query object. This is the root type for all 22 | queries and it looks like this: 23 | 24 | ```crystal 25 | require "graphql" 26 | 27 | @[GraphQL::Object] 28 | class Query < GraphQL::BaseQuery 29 | @[GraphQL::Field] 30 | def hello(name : String) : String 31 | "Hello, #{name}!" 32 | end 33 | end 34 | ``` 35 | 36 | Now we can create a schema object: 37 | 38 | ```crystal 39 | schema = GraphQL::Schema.new(Query.new) 40 | ``` 41 | 42 | To verify we did everything correctly, we can print out the schema: 43 | 44 | ```crystal 45 | puts schema.document.to_s 46 | ``` 47 | 48 | Which, among several built-in types, prints our query type: 49 | 50 | ```graphql 51 | type Query { 52 | hello(name: String!): String! 53 | } 54 | ``` 55 | 56 | 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: 57 | 58 | ```crystal 59 | post "/graphql" do |env| 60 | env.response.content_type = "application/json" 61 | 62 | query = env.params.json["query"].as(String) 63 | variables = env.params.json["variables"]?.as(Hash(String, JSON::Any)?) 64 | operation_name = env.params.json["operationName"]?.as(String?) 65 | 66 | schema.execute(query, variables, operation_name) 67 | end 68 | ``` 69 | 70 | Now we're ready to query our API: 71 | 72 | ```bash 73 | curl \ 74 | -X POST \ 75 | -H "Content-Type: application/json" \ 76 | --data '{ "query": "{ hello(name: \"John Doe\") }" }' \ 77 | http://0.0.0.0:3000/graphql 78 | ``` 79 | 80 | This should return: 81 | 82 | ```json 83 | { "data": { "hello": "Hello, John Doe!" } } 84 | ``` 85 | 86 | For easier development, we recommend using [GraphiQL](https://github.com/graphql/graphiql). 87 | A starter template combining Kemal and GraphiQL is found at [examples/graphiql](examples/graphiql). 88 | 89 | ## Context 90 | 91 | `context` is a optional argument that our fields can retrieve. It lets fields 92 | access global data, like database connections. 93 | 94 | ```crystal 95 | # Define our own context type 96 | class MyContext < GraphQL::Context 97 | @pi : Float64 98 | def initialize(@pi) 99 | end 100 | end 101 | 102 | # Pass it to schema.execute 103 | context = MyContext.new(Math::PI) 104 | schema.execute(query, variables, operation_name, context) 105 | 106 | # Access it in our fields 107 | @[GraphQL::Object] 108 | class MyMath < GraphQL::BaseObject 109 | @[GraphQL::Field] 110 | def pi(context : MyContext) : Float64 111 | context.pi 112 | end 113 | end 114 | ``` 115 | 116 | Context instances must not be reused for multiple executions. 117 | 118 | ## Objects 119 | 120 | Objects are perhaps the most commonly used type in GraphQL. They are implemented 121 | as classes. To define a object, we need a `GraphQL::Object` annotation and to inherit 122 | `GraphQL::BaseObject`. Fields are methods with a `GraphQL::Field` annotation. 123 | 124 | ```crystal 125 | @[GraphQL::Object] 126 | class Foo < GraphQL::BaseObject 127 | # type restrictions are mandatory on fields 128 | @[GraphQL::Field] 129 | def hello(first_name : String, last_name : String) : String 130 | "Hello #{first_name} #{last_name}" 131 | end 132 | 133 | # besides basic types, we can also return other objects 134 | @[GraphQL::Field] 135 | def bar : Bar 136 | Bar.new 137 | end 138 | end 139 | 140 | @[GraphQL::Object] 141 | class Bar < GraphQL::BaseObject 142 | @[GraphQL::Field] 143 | def baz : Float64 144 | 42_f64 145 | end 146 | end 147 | ``` 148 | 149 | For simple objects, we can use instance variables: 150 | 151 | ```crystal 152 | @[GraphQL::Object] 153 | class Foo < GraphQL::BaseObject 154 | @[GraphQL::Field] 155 | property bar : String 156 | 157 | @[GraphQL::Field] 158 | getter baz : Float64 159 | 160 | def initialize(@bar, @baz) 161 | end 162 | end 163 | ``` 164 | 165 | ## Query 166 | 167 | Query is the root type of all queries. 168 | 169 | ```crystal 170 | @[GraphQL::Object] 171 | class Query < GraphQL::BaseQuery 172 | @[GraphQL::Field] 173 | def echo(str : String) : String 174 | str 175 | end 176 | end 177 | 178 | schema = GraphQL::Schema.new(Query.new) 179 | ``` 180 | 181 | ## Mutation 182 | 183 | Mutation is the root type for all mutations. 184 | 185 | ```crystal 186 | @[GraphQL::Object] 187 | class Mutation < GraphQL::BaseMutation 188 | @[GraphQL::Field] 189 | def echo(str : String) : String 190 | str 191 | end 192 | end 193 | 194 | schema = GraphQL::Schema.new(Query.new, Mutation.new) 195 | ``` 196 | 197 | ## Input Objects 198 | 199 | Input objects are objects that are used as field arguments. To define an input 200 | object, use a `GraphQL::InputObject` annotation and inherit `GraphQL::BaseInputObject`. 201 | It must define a constructor with a `GraphQL::Field` annotation. 202 | 203 | ```crystal 204 | @[GraphQL::InputObject] 205 | class User < GraphQL::BaseInputObject 206 | getter first_name : String? 207 | getter last_name : String? 208 | 209 | @[GraphQL::Field] 210 | def initialize(@first_name : String?, @last_name : String?) 211 | end 212 | end 213 | ``` 214 | 215 | ## Enums 216 | 217 | Defining enums is straightforward. Just add a `GraphQL::Enum` annotation: 218 | 219 | ```crystal 220 | @[GraphQL::Enum] 221 | enum IPAddressType 222 | IPv4 223 | IPv6 224 | end 225 | ``` 226 | 227 | ## Scalars 228 | 229 | The following scalar values are supported: 230 | 231 | - `Int32` <-> `Int` 232 | - `Float64` <-> `Float` 233 | - `String` <-> `String` 234 | - `Bool` <-> `Boolean` 235 | - `GraphQL::Scalars::ID` <-> `String` 236 | 237 | Built-in custom scalars: 238 | 239 | - `GraphQL::Scalars::BigInt` <-> `String` 240 | 241 | Custom scalars are created by implementing from_json/to_json: 242 | 243 | ```crystal 244 | @[GraphQL::Scalar] 245 | class ReverseStringScalar < GraphQL::BaseScalar 246 | @value : String 247 | 248 | def initialize(@value) 249 | end 250 | 251 | def self.from_json(string_or_io) 252 | self.new(String.from_json(string_or_io).reverse) 253 | end 254 | 255 | def to_json(builder : JSON::Builder) 256 | builder.scalar(@value.reverse) 257 | end 258 | end 259 | ``` 260 | 261 | ## Interfaces 262 | 263 | Interfaces are not supported. 264 | 265 | ## Subscriptions 266 | 267 | Subscriptions are not supported. 268 | 269 | ## Annotation Arguments 270 | 271 | ### name 272 | 273 | Supported on: `Object`, `InputObject`, `Field`, `Enum`, `Scalar` 274 | 275 | We can use the `name` argument to customize the introspection type name of a 276 | type. This is not needed in most situations because type names are automatically 277 | converted to PascalCase or camelCase. However, `item_id` converts to 278 | `itemId`, but we might want to use `itemID`. For this, we can use the `name` 279 | argument. 280 | 281 | ```crystal 282 | @[GraphQL::Object(name: "Sheep")] 283 | class Wolf 284 | @[GraphQL::Field(name: "baa")] 285 | def howl : String 286 | "baa" 287 | end 288 | end 289 | ``` 290 | 291 | ### description 292 | 293 | Supported on: `Object`, `InputObject`, `Field`, `Enum`, `Scalar` 294 | 295 | Describes the type. Descriptions are available through the introspection interface 296 | so it's always a good idea to set this argument. 297 | 298 | ```crystal 299 | @[GraphQL::Object(description: "I'm a sheep, I promise!")] 300 | class Wolf 301 | end 302 | ``` 303 | 304 | ### deprecated 305 | 306 | Supported on: `Field` 307 | 308 | The deprecated argument marks a type as deprecated. 309 | 310 | ```crystal 311 | class Sheep 312 | @[GraphQL::Field(deprecated: "This was a bad idea.")] 313 | def fight_wolf : String 314 | "Wolf ate sheep" 315 | end 316 | end 317 | ``` 318 | 319 | ### arguments 320 | 321 | Sets names and descriptions for field arguments. Note that 322 | arguments cannot be marked as deprecated. 323 | 324 | ```crystal 325 | class Sheep 326 | @[GraphQL::Field(arguments: {weapon: {name: "weaponName", description: "The weapon the sheep should use."}})] 327 | def fight_wolf(weapon : String) : String 328 | if weapon == "Atomic Bomb" 329 | "Sheep killed wolf" 330 | else 331 | "Wolf ate sheep" 332 | end 333 | end 334 | end 335 | ``` 336 | 337 | ## Field Arguments 338 | 339 | Field arguments are automatically resolved. A type with a default value becomes 340 | optional. A nilable type is also considered a optional type. 341 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 55 | 60 | 61 | 65 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 61 | 66 | 67 | 114 | -------------------------------------------------------------------------------- /examples/graphiql/.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /bin 3 | shard.lock -------------------------------------------------------------------------------- /examples/graphiql/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
Loading...
24 | 25 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /examples/graphiql/main.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "graphql" 3 | 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def hello(name : String) : String 8 | "Hello, #{name}!" 9 | end 10 | end 11 | 12 | schema = GraphQL::Schema.new(Query.new) 13 | 14 | post "/graphql" do |env| 15 | env.response.content_type = "application/json" 16 | 17 | query = env.params.json["query"].as(String) 18 | variables = env.params.json["variables"]?.as(Hash(String, JSON::Any)?) 19 | operation_name = env.params.json["operationName"]?.as(String?) 20 | 21 | schema.execute(query, variables, operation_name) 22 | end 23 | 24 | get "/" do 25 | render "index.html" 26 | end 27 | 28 | Kemal.run 29 | -------------------------------------------------------------------------------- /examples/graphiql/shard.yml: -------------------------------------------------------------------------------- 1 | name: graphql-example-graphiql 2 | version: 1.0.0 3 | license: MIT 4 | dependencies: 5 | kemal: 6 | github: kemalcr/kemal 7 | graphql: 8 | github: graphql-crystal/graphql -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":disableDependencyDashboard" 5 | ], 6 | "packageRules": [ 7 | { 8 | "updateTypes": [ 9 | "minor", 10 | "patch", 11 | "pin", 12 | "digest" 13 | ], 14 | "automerge": true 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: graphql 2 | version: 0.4.0 3 | authors: 4 | - Jakob Gillich 5 | license: MIT 6 | description: | 7 | GraphQL server library 8 | crystal: ">= 1.4.0" 9 | development_dependencies: 10 | ameba: 11 | github: crystal-ameba/ameba 12 | branch: master 13 | kemal: 14 | github: kemalcr/kemal 15 | -------------------------------------------------------------------------------- /spec/array_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module ArrayFixture 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def nested(arr : Array(Array(Array(String)))) : Array(Array(Array(String))) 8 | arr 9 | end 10 | end 11 | end 12 | 13 | describe GraphQL do 14 | it "resolves nested arrays" do 15 | GraphQL::Schema.new(ArrayFixture::Query.new).execute( 16 | %( 17 | { 18 | nested(arr: [[["foo"]]]) 19 | } 20 | ) 21 | ).should eq ( 22 | { 23 | "data" => { 24 | "nested" => [[["foo"]]], 25 | }, 26 | } 27 | ).to_json 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/base_classes_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe GraphQL::BaseObject do 4 | it "creates schema through inheritance class" do 5 | GraphQL::Schema.new(BaseClassesFixture::Query.new).execute( 6 | %( 7 | { 8 | result: helloWorld 9 | } 10 | ) 11 | ).should eq ( 12 | { 13 | "data" => { 14 | "result" => "hello world", 15 | }, 16 | } 17 | ).to_json 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/bigint_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module BigIntFixture 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def add(bi : GraphQL::Scalars::BigInt, i : Int32) : GraphQL::Scalars::BigInt 8 | GraphQL::Scalars::BigInt.new(bi.value + i) 9 | end 10 | end 11 | end 12 | 13 | describe GraphQL::Scalars::BigInt do 14 | it "echos bigint" do 15 | GraphQL::Schema.new(BigIntFixture::Query.new).execute( 16 | %( 17 | { 18 | add(bi: "12345678901234567890", i: 1) 19 | } 20 | ) 21 | ).should eq ( 22 | { 23 | "data" => { 24 | "add" => "12345678901234567891", 25 | }, 26 | } 27 | ).to_json 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/context_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module ContextApi 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def value(ctx : Context) : Int32 8 | ctx.value 9 | end 10 | 11 | @[GraphQL::Field] 12 | def exception(message : String) : Int32 13 | raise message 14 | end 15 | end 16 | 17 | class Context < GraphQL::Context 18 | getter value : Int32 19 | 20 | def initialize(@value) 21 | end 22 | end 23 | 24 | class ExceptionContext < GraphQL::Context 25 | def handle_exception(ex : ::Exception) : String? 26 | raise ex 27 | end 28 | end 29 | end 30 | 31 | describe GraphQL::Context do 32 | it "returns value from context" do 33 | value = 1337 34 | 35 | ctx = ContextApi::Context.new(value) 36 | schema = GraphQL::Schema.new(ContextApi::Query.new) 37 | 38 | query = %( 39 | { 40 | value 41 | } 42 | ) 43 | 44 | schema.execute(query, context: ctx).should eq ( 45 | { 46 | "data" => { 47 | "value" => value, 48 | }, 49 | } 50 | ).to_json 51 | end 52 | 53 | it "handles exceptions" do 54 | ctx = ContextApi::Context.new(1337) 55 | schema = GraphQL::Schema.new(ContextApi::Query.new) 56 | 57 | query = %( 58 | { 59 | exception(message: "boom") 60 | } 61 | ) 62 | 63 | schema.execute(query, context: ctx).should eq ( 64 | { 65 | "data" => {} of String => JSON::Any, 66 | "errors" => [ 67 | {"message" => "boom", "path" => ["exception"]}, 68 | ], 69 | } 70 | ).to_json 71 | end 72 | 73 | it "bubbles up exceptions" do 74 | ctx = ContextApi::ExceptionContext.new 75 | schema = GraphQL::Schema.new(ContextApi::Query.new) 76 | 77 | query = %( 78 | { 79 | exception(message: "error") 80 | } 81 | ) 82 | 83 | expect_raises(Exception, "error") do 84 | schema.execute(query, context: ctx) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/enums/as_argument_array_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsArgumentArray 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Query 14 | include GraphQL::ObjectType 15 | include GraphQL::QueryType 16 | 17 | @[GraphQL::Field] 18 | def episodes_to_string(episodes : Array(Episode)) : String 19 | episodes.join(", ") 20 | end 21 | end 22 | end 23 | 24 | class GraphqlRequest 25 | include JSON::Serializable 26 | 27 | @[JSON::Field(key: "variables")] 28 | property variables : Hash(String, JSON::Any)? 29 | end 30 | 31 | describe GraphQL::Enum do 32 | it "returns the correct value" do 33 | GraphQL::Schema.new(TestEnumsAsArgumentArray::Query.new).execute( 34 | %( 35 | query { 36 | result: episodesToString(episodes: [JEDI,NEWHOPE] ) 37 | } 38 | ) 39 | ).should eq ( 40 | { 41 | "data" => { 42 | "result" => "JEDI, NEWHOPE", 43 | }, 44 | } 45 | ).to_json 46 | end 47 | 48 | it "returns the correct value with variable" do 49 | request = {"variables" => {"e" => ["EMPIRE", "NEWHOPE"]}}.to_json 50 | 51 | graphql_request = GraphqlRequest.from_json(request) 52 | 53 | GraphQL::Schema.new(TestEnumsAsArgumentArray::Query.new).execute( 54 | %( 55 | query($e: [Episode!]!) { 56 | result: episodesToString(episodes: $e) 57 | } 58 | ), 59 | graphql_request.variables 60 | ).should eq ( 61 | { 62 | "data" => { 63 | "result" => "EMPIRE, NEWHOPE", 64 | }, 65 | } 66 | ).to_json 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/enums/as_argument_default_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsDefaultArgument 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Query 14 | include GraphQL::ObjectType 15 | include GraphQL::QueryType 16 | 17 | @[GraphQL::Field] 18 | def episode_to_string(episode : Episode? = Episode::EMPIRE) : String 19 | episode.to_s 20 | end 21 | end 22 | end 23 | 24 | describe GraphQL::Enum do 25 | it "returns the correct value" do 26 | GraphQL::Schema.new(TestEnumsAsDefaultArgument::Query.new).execute( 27 | %( 28 | query { 29 | result: episodeToString 30 | } 31 | ) 32 | ).should eq ( 33 | { 34 | "data" => { 35 | "result" => "EMPIRE", 36 | }, 37 | } 38 | ).to_json 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/enums/as_argument_optional_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsOptionalArgument 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Query 14 | include GraphQL::ObjectType 15 | include GraphQL::QueryType 16 | 17 | @[GraphQL::Field] 18 | def episode_to_string(episode : Episode?) : String 19 | return "No Episode Given" if episode.nil? 20 | 21 | episode.to_s 22 | end 23 | end 24 | end 25 | 26 | describe GraphQL::Enum do 27 | it "returns the correct value" do 28 | GraphQL::Schema.new(TestEnumsAsOptionalArgument::Query.new).execute( 29 | %( 30 | query { 31 | result: episodeToString 32 | } 33 | ) 34 | ).should eq ( 35 | { 36 | "data" => { 37 | "result" => "No Episode Given", 38 | }, 39 | } 40 | ).to_json 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/enums/as_argument_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsArgument 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Query 14 | include GraphQL::ObjectType 15 | include GraphQL::QueryType 16 | 17 | @[GraphQL::Field] 18 | def episode_to_string(episode : Episode) : String? 19 | episode.to_s 20 | end 21 | end 22 | end 23 | 24 | class GraphqlRequest 25 | include JSON::Serializable 26 | 27 | @[JSON::Field(key: "variables")] 28 | property variables : Hash(String, JSON::Any)? 29 | end 30 | 31 | describe GraphQL::Enum do 32 | it "returns the correct value" do 33 | GraphQL::Schema.new(TestEnumsAsArgument::Query.new).execute( 34 | %( 35 | query { 36 | result: episodeToString(episode: JEDI) 37 | } 38 | ) 39 | ).should eq ( 40 | { 41 | "data" => { 42 | "result" => "JEDI", 43 | }, 44 | } 45 | ).to_json 46 | end 47 | 48 | it "returns the correct value with variable" do 49 | request = {"variables" => {"e" => "EMPIRE"}}.to_json 50 | 51 | graphql_request = GraphqlRequest.from_json(request) 52 | 53 | GraphQL::Schema.new(TestEnumsAsArgument::Query.new).execute( 54 | %( 55 | query($e: Episode!) { 56 | result: episodeToString(episode: $e) 57 | } 58 | ), 59 | graphql_request.variables 60 | ).should eq ( 61 | { 62 | "data" => { 63 | "result" => "EMPIRE", 64 | }, 65 | } 66 | ).to_json 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/enums/as_input_argument_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsInputArgument 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::InputObject] 13 | class MyInput 14 | include GraphQL::InputObjectType 15 | 16 | getter my_enum 17 | 18 | @[GraphQL::Field] 19 | def initialize(@my_enum : Episode) 20 | end 21 | end 22 | 23 | @[GraphQL::Object] 24 | class Query 25 | include GraphQL::ObjectType 26 | include GraphQL::QueryType 27 | 28 | @[GraphQL::Field] 29 | def episodes_to_string(episode : MyInput) : String 30 | episode.my_enum.to_s 31 | end 32 | end 33 | end 34 | 35 | class GraphqlRequest 36 | include JSON::Serializable 37 | 38 | @[JSON::Field(key: "variables")] 39 | property variables : Hash(String, JSON::Any)? 40 | end 41 | 42 | describe GraphQL::Enum do 43 | it "returns the correct value" do 44 | GraphQL::Schema.new(TestEnumsAsInputArgument::Query.new).execute( 45 | %( 46 | query { 47 | result: episodesToString(episode: {myEnum: "JEDI"} ) 48 | } 49 | ) 50 | ).should eq ( 51 | { 52 | "data" => { 53 | "result" => "JEDI", 54 | }, 55 | } 56 | ).to_json 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/enums/as_return_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../../src/graphql" 3 | 4 | module TestEnumsAsReturn 5 | @[GraphQL::Enum(description: "List of Starwars episodes")] 6 | enum Episode 7 | NEWHOPE 8 | EMPIRE 9 | JEDI 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Query 14 | include GraphQL::ObjectType 15 | include GraphQL::QueryType 16 | 17 | @[GraphQL::Field] 18 | def a_episode : Episode 19 | Episode::NEWHOPE 20 | end 21 | end 22 | end 23 | 24 | describe GraphQL::Enum do 25 | it "returns the correct value" do 26 | GraphQL::Schema.new(TestEnumsAsReturn::Query.new).execute( 27 | %( 28 | query { aEpisode } 29 | ) 30 | ).should eq ( 31 | { 32 | "data" => { 33 | "aEpisode" => "NEWHOPE", 34 | }, 35 | } 36 | ).to_json 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/exception_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module ExceptionFixture 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def err : String? 8 | raise "foo" 9 | end 10 | end 11 | 12 | class Context < GraphQL::Context 13 | def handle_exception(ex : Exception) : String? 14 | "handled" 15 | end 16 | end 17 | end 18 | 19 | describe Exception do 20 | it "handles exception" do 21 | GraphQL::Schema.new(ExceptionFixture::Query.new).execute( 22 | %( 23 | { 24 | err 25 | } 26 | ), 27 | context: ExceptionFixture::Context.new 28 | ).should eq ( 29 | { 30 | "data" => {} of Nil => Nil, 31 | "errors" => [ 32 | { 33 | "message" => "handled", 34 | "path" => ["err"], 35 | }, 36 | ], 37 | } 38 | ).to_json 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/fixtures/base_classes.cr: -------------------------------------------------------------------------------- 1 | require "../../src/graphql" 2 | 3 | module BaseClassesFixture 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | def hello_world : String 8 | "hello world" 9 | end 10 | end 11 | 12 | @[GraphQL::Object] 13 | class Mutation < GraphQL::BaseMutation 14 | @[GraphQL::Field] 15 | def hello_world : String 16 | "hello world" 17 | end 18 | end 19 | 20 | @[GraphQL::Object] 21 | class Object < GraphQL::BaseObject 22 | @[GraphQL::Field] 23 | def hello_world : String 24 | "hello world" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/fixtures/empty_query.cr: -------------------------------------------------------------------------------- 1 | require "../../src/graphql" 2 | 3 | module EmptyQueryFixture 4 | @[GraphQL::Object] 5 | class Query 6 | include GraphQL::ObjectType 7 | include GraphQL::QueryType 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/mutation.cr: -------------------------------------------------------------------------------- 1 | require "../../src/graphql" 2 | 3 | module MutationFixture 4 | @[GraphQL::InputObject] 5 | class MutationInputObject 6 | include GraphQL::InputObjectType 7 | 8 | getter value : String 9 | 10 | @[GraphQL::Field] 11 | def initialize(@value : String) 12 | end 13 | end 14 | 15 | @[GraphQL::InputObject] 16 | class NestedMutationInputObject 17 | include GraphQL::InputObjectType 18 | 19 | getter value : NestedMutationInputObject? 20 | 21 | @[GraphQL::Field] 22 | def initialize(@value : NestedMutationInputObject?) 23 | end 24 | end 25 | 26 | @[GraphQL::Object] 27 | class Mutation 28 | include GraphQL::ObjectType 29 | include GraphQL::MutationType 30 | 31 | @[GraphQL::Field] 32 | def non_null(io : MutationInputObject) : String 33 | io.value 34 | end 35 | 36 | @[GraphQL::Field] 37 | def maybe_null(io : MutationInputObject?) : String? 38 | io.value unless io.nil? 39 | end 40 | 41 | @[GraphQL::Field] 42 | def nested(io : NestedMutationInputObject) : Int32 43 | i = 0 44 | current = io 45 | loop do 46 | i += 1 47 | if value = current.value 48 | current = value 49 | else 50 | break 51 | end 52 | end 53 | i 54 | end 55 | 56 | @[GraphQL::Field] 57 | def array(io : Array(MutationInputObject)?, strings : Array(String)?, ints : Array(Int32)?, floats : Array(Float64)?) : Array(String) 58 | return io.map &.value unless io.nil? 59 | return strings unless strings.nil? 60 | return ints.map &.to_s unless ints.nil? 61 | return floats.map &.to_s unless floats.nil? 62 | [] of String 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/fixtures/mutation.graphql: -------------------------------------------------------------------------------- 1 | "The `Boolean` scalar type represents `true` or `false`." 2 | scalar Boolean 3 | 4 | "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)." 5 | scalar Float 6 | 7 | "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID." 8 | scalar ID 9 | 10 | "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." 11 | scalar Int 12 | 13 | type Query { 14 | } 15 | 16 | "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." 17 | scalar String 18 | 19 | type __Directive { 20 | args: [__InputValue!]! 21 | description: String 22 | locations: [String!]! 23 | name: String! 24 | } 25 | 26 | type __EnumValue { 27 | deprecationReason: String 28 | description: String 29 | isDeprecated: Boolean! 30 | name: String! 31 | } 32 | 33 | type __Field { 34 | args: [__InputValue!]! 35 | deprecationReason: String 36 | description: String 37 | isDeprecated: Boolean! 38 | name: String! 39 | type: __Type! 40 | } 41 | 42 | type __InputValue { 43 | defaultValue: String 44 | description: String 45 | name: String! 46 | type: __Type! 47 | } 48 | 49 | type __Schema { 50 | directives: [__Directive!]! 51 | mutationType: __Type 52 | queryType: __Type! 53 | subscriptionType: __Type 54 | types: [__Type!]! 55 | } 56 | 57 | type __Type { 58 | description: String 59 | enumValues(includeDeprecated: Boolean! = false): [__EnumValue!] 60 | fields(includeDeprecated: Boolean! = false): [__Field!] 61 | inputFields: [__InputValue!] 62 | interfaces: [__Type!] 63 | kind: __TypeKind! 64 | name: String 65 | ofType: __Type 66 | possibleTypes: [__Type!] 67 | } 68 | 69 | enum __TypeKind { 70 | ENUM 71 | INPUT_OBJECT 72 | INTERFACE 73 | LIST 74 | NON_NULL 75 | OBJECT 76 | SCALAR 77 | UNION 78 | } 79 | 80 | type Mutation { 81 | array(floats: [Float!], ints: [Int!], io: [MutationInputObject!], strings: [String!]): [String!]! 82 | maybeNull(io: MutationInputObject): String 83 | nested(io: NestedMutationInputObject!): Int! 84 | nonNull(io: MutationInputObject!): String! 85 | } 86 | 87 | input MutationInputObject { 88 | value: String! 89 | } 90 | 91 | input NestedMutationInputObject { 92 | value: NestedMutationInputObject 93 | } -------------------------------------------------------------------------------- /spec/fixtures/query.cr: -------------------------------------------------------------------------------- 1 | require "../../src/graphql" 2 | 3 | module QueryFixture 4 | @[GraphQL::Object] 5 | class Query 6 | include GraphQL::ObjectType 7 | include GraphQL::QueryType 8 | 9 | @[GraphQL::Field(name: "ann_ks", description: "Annotations", arguments: { 10 | arg_with_descr: {description: "arg_with_descr description"}, 11 | arg_with_name: {name: "argWithNameOverride"}, 12 | arg_with_both: {name: "argWithBothOverride", description: "arg_with_both description"}, 13 | })] 14 | def annotation_kitchen_sink(arg_with_descr : String, arg_with_name : String, arg_with_both : String, arg_with_none : String) : String 15 | "" 16 | end 17 | 18 | @[GraphQL::Field] 19 | def args_without_annotations(arg1 : String, arg2 : String, arg3 : String) : String 20 | "" 21 | end 22 | 23 | @[GraphQL::Field] 24 | def args_default_values(arg1 : String = "Default", arg2 : Int32 = 123, arg3 : Float64 = 1.23) : String 25 | "" 26 | end 27 | 28 | @[GraphQL::Field] 29 | def echo_nested_input_object(nested_input_object : NestedInputObject) : NestedObject 30 | NestedObject.new(nested_input_object) 31 | end 32 | 33 | @[GraphQL::Field] 34 | def default_values(int : Int32 = 1, float : Float64 = 2.0, emptyStr : String = "", str : String = "qwe", bool : Bool = false) : String 35 | "" 36 | end 37 | 38 | @[GraphQL::Field] 39 | def record(value : String) : RecordResolver 40 | RecordResolver.new(value) 41 | end 42 | end 43 | 44 | @[GraphQL::InputObject] 45 | class NestedInputObject 46 | include GraphQL::InputObjectType 47 | 48 | getter object : NestedInputObject? 49 | getter array : Array(NestedInputObject)? 50 | getter str : String? 51 | getter int : Int32? 52 | getter float : Float64? 53 | 54 | @[GraphQL::Field] 55 | def initialize(@object : NestedInputObject?, @array : Array(NestedInputObject)?, @str : String?, @int : Int32?, @float : Float64?) 56 | end 57 | end 58 | 59 | @[GraphQL::Object] 60 | class NestedObject 61 | include GraphQL::ObjectType 62 | 63 | @object : NestedObject? 64 | @array : Array(NestedObject)? 65 | @str : String? 66 | @int : Int32? 67 | @float : Float64? 68 | 69 | def initialize(object : NestedInputObject) 70 | @object = NestedObject.new(object.object.not_nil!) unless object.object.nil? 71 | @array = object.array.not_nil!.map { |io| NestedObject.new(io).as(NestedObject) }.as(Array(NestedObject) | Nil) unless object.array.nil? 72 | @str = object.str 73 | @int = object.int 74 | @float = object.float 75 | end 76 | 77 | @[GraphQL::Field] 78 | def object : NestedObject? 79 | @object 80 | end 81 | 82 | @[GraphQL::Field] 83 | def array : Array(NestedObject)? 84 | @array 85 | end 86 | 87 | @[GraphQL::Field] 88 | def str : String? 89 | @str 90 | end 91 | 92 | @[GraphQL::Field] 93 | def str_reverse : ReverseStringScalar? 94 | if str = @str 95 | ReverseStringScalar.new(str) 96 | end 97 | end 98 | 99 | @[GraphQL::Field] 100 | def id : GraphQL::Scalars::ID? 101 | if str = @str 102 | GraphQL::Scalars::ID.new(str) 103 | end 104 | end 105 | 106 | @[GraphQL::Field] 107 | def int : Int32? 108 | @int 109 | end 110 | 111 | @[GraphQL::Field] 112 | def float : Float64? 113 | @float 114 | end 115 | end 116 | 117 | @[GraphQL::Scalar] 118 | class ReverseStringScalar 119 | include GraphQL::ScalarType 120 | 121 | @value : String 122 | 123 | def initialize(@value) 124 | end 125 | 126 | def to_json(builder : JSON::Builder) 127 | builder.scalar(@value.reverse) 128 | end 129 | end 130 | 131 | @[GraphQL::Object(description: "RecordResolver description")] 132 | record RecordResolver, value : ::String do 133 | include GraphQL::ObjectType 134 | 135 | @[GraphQL::Field] 136 | def value : String 137 | @value 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/fixtures/query.graphql: -------------------------------------------------------------------------------- 1 | "The `Boolean` scalar type represents `true` or `false`." 2 | scalar Boolean 3 | 4 | "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)." 5 | scalar Float 6 | 7 | "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID." 8 | scalar ID 9 | 10 | "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." 11 | scalar Int 12 | 13 | input NestedInputObject { 14 | object: NestedInputObject 15 | array: [NestedInputObject!] 16 | str: String 17 | int: Int 18 | float: Float 19 | } 20 | 21 | type NestedObject { 22 | array: [NestedObject!] 23 | float: Float 24 | id: ID 25 | int: Int 26 | object: NestedObject 27 | str: String 28 | strReverse: ReverseStringScalar 29 | } 30 | 31 | type Query { 32 | "Annotations" 33 | ann_ks( 34 | "arg_with_both description" 35 | argWithBothOverride: String! 36 | "arg_with_descr description" 37 | argWithDescr: String! 38 | argWithNameOverride: String! 39 | argWithNone: String! 40 | ): String! 41 | argsDefaultValues(arg1: String! = "Default", arg2: Int! = 123, arg3: Float! = 1.23): String! 42 | argsWithoutAnnotations(arg1: String!, arg2: String!, arg3: String!): String! 43 | defaultValues(bool: Boolean! = false, emptyStr: String! = "", float: Float! = 2.0, int: Int! = 1, str: String! = "qwe"): String! 44 | echoNestedInputObject(nestedInputObject: NestedInputObject!): NestedObject! 45 | record(value: String!): RecordResolver! 46 | } 47 | 48 | "RecordResolver description" 49 | type RecordResolver { 50 | value: String! 51 | } 52 | 53 | scalar ReverseStringScalar 54 | 55 | "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." 56 | scalar String 57 | 58 | type __Directive { 59 | args: [__InputValue!]! 60 | description: String 61 | locations: [String!]! 62 | name: String! 63 | } 64 | 65 | type __EnumValue { 66 | deprecationReason: String 67 | description: String 68 | isDeprecated: Boolean! 69 | name: String! 70 | } 71 | 72 | type __Field { 73 | args: [__InputValue!]! 74 | deprecationReason: String 75 | description: String 76 | isDeprecated: Boolean! 77 | name: String! 78 | type: __Type! 79 | } 80 | 81 | type __InputValue { 82 | defaultValue: String 83 | description: String 84 | name: String! 85 | type: __Type! 86 | } 87 | 88 | type __Schema { 89 | directives: [__Directive!]! 90 | mutationType: __Type 91 | queryType: __Type! 92 | subscriptionType: __Type 93 | types: [__Type!]! 94 | } 95 | 96 | type __Type { 97 | description: String 98 | enumValues(includeDeprecated: Boolean! = false): [__EnumValue!] 99 | fields(includeDeprecated: Boolean! = false): [__Field!] 100 | inputFields: [__InputValue!] 101 | interfaces: [__Type!] 102 | kind: __TypeKind! 103 | name: String 104 | ofType: __Type 105 | possibleTypes: [__Type!] 106 | } 107 | 108 | enum __TypeKind { 109 | ENUM 110 | INPUT_OBJECT 111 | INTERFACE 112 | LIST 113 | NON_NULL 114 | OBJECT 115 | SCALAR 116 | UNION 117 | } -------------------------------------------------------------------------------- /spec/fixtures/star_wars.cr: -------------------------------------------------------------------------------- 1 | module StarWars 2 | CHARACTERS = [ 3 | Human.new( 4 | id: "1000", 5 | name: "Luke Skywalker", 6 | friends: ["1002", "1003", "2000", "2001"], 7 | appears_in: [Episode::IV, Episode::V, Episode::VI], 8 | home_planet: "Tatooine", 9 | lightsabers: [Lightsaber.new(color: "green")] 10 | ), 11 | Human.new( 12 | id: "1001", 13 | name: "Darth Vader", 14 | friends: ["1004"], 15 | appears_in: [Episode::IV, Episode::V, Episode::VI], 16 | home_planet: "Tatooine" 17 | ), 18 | Human.new( 19 | id: "1002", 20 | name: "Han Solo", 21 | friends: ["1000", "1003", "2001"], 22 | appears_in: [Episode::IV, Episode::V, Episode::VI], 23 | ), 24 | Human.new( 25 | id: "1003", 26 | name: "Leia Organa", 27 | friends: ["1000", "1002", "2000", "2001"], 28 | appears_in: [Episode::IV, Episode::V, Episode::VI], 29 | home_planet: "Alderaan", 30 | ), 31 | Human.new( 32 | id: "1004", 33 | name: "Wilhuff Tarkin", 34 | friends: ["1001"], 35 | appears_in: [Episode::IV], 36 | ), 37 | Droid.new( 38 | id: "2000", 39 | name: "C-3PO", 40 | friends: ["1000", "1002", "1003", "2001"], 41 | appears_in: [Episode::IV, Episode::V, Episode::VI], 42 | primary_function: "Protocol", 43 | ), 44 | Droid.new( 45 | id: "2001", 46 | name: "R2-D2", 47 | friends: ["1000", "1002", "1003"], 48 | appears_in: [Episode::IV, Episode::V, Episode::VI], 49 | primary_function: "Astromech", 50 | ), 51 | ] of Human | Droid 52 | 53 | @[GraphQL::Enum(description: "List of starwars episodes")] 54 | enum Episode 55 | IV 56 | V 57 | VI 58 | end 59 | 60 | @[GraphQL::Object] 61 | abstract class Character 62 | include GraphQL::ObjectType 63 | 64 | @id : String 65 | @name : String 66 | @friends : Array(String) 67 | @appears_in : Array(Episode) 68 | 69 | def initialize(@id, @name, @friends, @appears_in) 70 | end 71 | 72 | @[GraphQL::Field] 73 | def id : String 74 | @id 75 | end 76 | 77 | @[GraphQL::Field] 78 | def name : String 79 | @name 80 | end 81 | 82 | # @[GraphQL::Field] 83 | # def friends : Array(Character) 84 | # @friends 85 | # end 86 | end 87 | 88 | @[GraphQL::Object] 89 | class Lightsaber 90 | include GraphQL::ObjectType 91 | 92 | @[GraphQL::Field] 93 | property color : String 94 | 95 | def initialize(@color : String) 96 | end 97 | end 98 | 99 | @[GraphQL::Object] 100 | class Human < Character 101 | include GraphQL::ObjectType 102 | 103 | @home_planet : String? 104 | 105 | @[GraphQL::Field] 106 | property lightsabers : Array(Lightsaber) 107 | 108 | def initialize(@id, @name, @friends, @appears_in, @lightsabers = [] of Lightsaber, @home_planet = nil) 109 | end 110 | 111 | @[GraphQL::Field] 112 | def home_planet : String? 113 | @home_planet 114 | end 115 | end 116 | 117 | @[GraphQL::Object] 118 | class Droid < Character 119 | include GraphQL::ObjectType 120 | 121 | @primary_function : String 122 | 123 | def initialize(@id, @name, @friends, @appears_in, @primary_function) 124 | end 125 | 126 | @[GraphQL::Field] 127 | def primary_function : String 128 | @primary_function 129 | end 130 | end 131 | 132 | @[GraphQL::Object] 133 | class Query 134 | include GraphQL::ObjectType 135 | include GraphQL::QueryType 136 | 137 | @[GraphQL::Field(description: "Get hero for episode", arguments: { 138 | episode: {description: "The episode"}, 139 | })] 140 | def hero(episode : Episode) : Human 141 | humans.first 142 | end 143 | 144 | @[GraphQL::Field] 145 | def humans : Array(Human) 146 | humans = [] of Human 147 | CHARACTERS.select(Human).each { |h| humans << h.as(Human) } 148 | humans 149 | end 150 | 151 | @[GraphQL::Field] 152 | def human(id : String) : Human? 153 | CHARACTERS.find { |c| c.is_a?(Human) && c.id == id }.as(Human) 154 | end 155 | 156 | @[GraphQL::Field] 157 | def droid(id : String) : Droid? 158 | CHARACTERS.find { |c| c.is_a?(Droid) && c.id == id }.as(Droid) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /spec/fixtures/star_wars.graphql: -------------------------------------------------------------------------------- 1 | "The `Boolean` scalar type represents `true` or `false`." 2 | scalar Boolean 3 | 4 | type Droid { 5 | id: String! 6 | name: String! 7 | primaryFunction: String! 8 | } 9 | 10 | "List of starwars episodes" 11 | enum Episode { 12 | IV 13 | V 14 | VI 15 | } 16 | 17 | "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)." 18 | scalar Float 19 | 20 | type Human { 21 | homePlanet: String 22 | id: String! 23 | lightsabers: [Lightsaber!]! 24 | name: String! 25 | } 26 | 27 | "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID." 28 | scalar ID 29 | 30 | "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1." 31 | scalar Int 32 | 33 | type Lightsaber { 34 | color: String! 35 | } 36 | 37 | type Query { 38 | droid(id: String!): Droid 39 | 40 | "Get hero for episode" 41 | hero( 42 | "The episode" 43 | episode: Episode! 44 | ): Human! 45 | human(id: String!): Human 46 | humans: [Human!]! 47 | } 48 | 49 | "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text." 50 | scalar String 51 | 52 | type __Directive { 53 | args: [__InputValue!]! 54 | description: String 55 | locations: [String!]! 56 | name: String! 57 | } 58 | 59 | type __EnumValue { 60 | deprecationReason: String 61 | description: String 62 | isDeprecated: Boolean! 63 | name: String! 64 | } 65 | 66 | type __Field { 67 | args: [__InputValue!]! 68 | deprecationReason: String 69 | description: String 70 | isDeprecated: Boolean! 71 | name: String! 72 | type: __Type! 73 | } 74 | 75 | type __InputValue { 76 | defaultValue: String 77 | description: String 78 | name: String! 79 | type: __Type! 80 | } 81 | 82 | type __Schema { 83 | directives: [__Directive!]! 84 | mutationType: __Type 85 | queryType: __Type! 86 | subscriptionType: __Type 87 | types: [__Type!]! 88 | } 89 | 90 | type __Type { 91 | description: String 92 | enumValues(includeDeprecated: Boolean! = false): [__EnumValue!] 93 | fields(includeDeprecated: Boolean! = false): [__Field!] 94 | inputFields: [__InputValue!] 95 | interfaces: [__Type!] 96 | kind: __TypeKind! 97 | name: String 98 | ofType: __Type 99 | possibleTypes: [__Type!] 100 | } 101 | 102 | enum __TypeKind { 103 | ENUM 104 | INPUT_OBJECT 105 | INTERFACE 106 | LIST 107 | NON_NULL 108 | OBJECT 109 | SCALAR 110 | UNION 111 | } -------------------------------------------------------------------------------- /spec/instance_var_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module InstanceVarFixture 4 | @[GraphQL::Object] 5 | class Query < GraphQL::BaseQuery 6 | @[GraphQL::Field] 7 | property prop : String? 8 | 9 | @[GraphQL::Field] 10 | getter getter : String? 11 | 12 | @[GraphQL::Field] 13 | property q : Query? 14 | 15 | def initialize(@prop, @getter, @q) 16 | end 17 | end 18 | end 19 | 20 | describe GraphQL do 21 | it "resolves instance vars" do 22 | schema = GraphQL::Schema.new(InstanceVarFixture::Query.new("123", "foo", InstanceVarFixture::Query.new("321", nil, nil))) 23 | 24 | schema.execute(%( 25 | { 26 | prop 27 | getter 28 | q { 29 | prop 30 | } 31 | } 32 | )).should eq ( 33 | { 34 | "data" => { 35 | "prop" => "123", 36 | "getter" => "foo", 37 | "q" => { 38 | "prop": "321", 39 | }, 40 | }, 41 | } 42 | ).to_json 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/language_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe GraphQL::Language do 4 | it "parses and generates query schema" do 5 | schema = {{ read_file("spec/fixtures/query.graphql") }}.strip 6 | schema.should eq GraphQL::Language.parse(schema).to_s 7 | end 8 | 9 | it "parses and generates mutation schema" do 10 | schema = {{ read_file("spec/fixtures/mutation.graphql") }}.strip 11 | schema.should eq GraphQL::Language.parse(schema).to_s 12 | end 13 | 14 | it "parses block string with quote" do 15 | schema = %( 16 | """ 17 | Description with quote " 18 | """ 19 | scalar Foo 20 | ) 21 | GraphQL::Language.parse(schema) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/mutation_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe GraphQL::MutationType do 4 | it "takes non-null input" do 5 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 6 | mutation Mutation { 7 | value: nonNull(io: {value: "123"}) 8 | } 9 | )).should eq ( 10 | { 11 | "data" => { 12 | "value" => "123", 13 | }, 14 | } 15 | ).to_json 16 | end 17 | 18 | it "takes null input" do 19 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 20 | mutation Mutation { 21 | value: maybeNull 22 | } 23 | )).should eq ( 24 | { 25 | "data" => { 26 | "value" => nil, 27 | }, 28 | } 29 | ).to_json 30 | end 31 | 32 | it "takes array of strings" do 33 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 34 | mutation Mutation($obj : [MutationInputObject]) { 35 | value: array(strings: ["one", "two", $three]) 36 | } 37 | ), 38 | {"three" => JSON::Any.new("three")} of String => JSON::Any 39 | ).should eq ( 40 | { 41 | "data" => {"value" => ["one", "two", "three"]}, 42 | } 43 | ).to_json 44 | end 45 | 46 | it "takes array of ints" do 47 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 48 | mutation Mutation($obj : [MutationInputObject]) { 49 | value: array(ints: [1, 2, $three]) 50 | } 51 | ), 52 | {"three" => JSON::Any.new(3_i64)} of String => JSON::Any 53 | ).should eq ( 54 | { 55 | "data" => {"value" => ["1", "2", "3"]}, 56 | } 57 | ).to_json 58 | end 59 | 60 | it "takes array of floats" do 61 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 62 | mutation Mutation($obj : [MutationInputObject]) { 63 | value: array(floats: [1.0, 2.0, $three]) 64 | } 65 | ), 66 | {"three" => JSON::Any.new(3_i64)} of String => JSON::Any 67 | ).should eq ( 68 | { 69 | "data" => {"value" => ["1.0", "2.0", "3.0"]}, 70 | } 71 | ).to_json 72 | end 73 | 74 | it "takes input object as variable" do 75 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 76 | mutation Mutation($obj : MutationInputObject) { 77 | value: nonNull(io: $obj) 78 | } 79 | ), 80 | {"obj" => JSON::Any.new({"value" => JSON::Any.new("123")})} of String => JSON::Any 81 | ).should eq ( 82 | { 83 | "data" => { 84 | "value" => "123", 85 | }, 86 | } 87 | ).to_json 88 | end 89 | 90 | it "takes nested input objects" do 91 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 92 | mutation Mutation($obj : MutationInputObject) { 93 | value: nested(io: $obj) 94 | } 95 | ), 96 | JSON.parse({"obj" => {"value" => {"value" => {"value" => nil}}}}.to_json).raw.as(Hash(String, JSON::Any)) 97 | ).should eq ( 98 | { 99 | "data" => { 100 | "value" => 3, 101 | }, 102 | } 103 | ).to_json 104 | end 105 | 106 | it "takes nested input objects with variable" do 107 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 108 | mutation Mutation($obj : NestedInputObject) { 109 | value: nested(io: { value: { value: $obj }}) 110 | } 111 | ), 112 | JSON.parse({"obj" => {"value" => nil}}.to_json).raw.as(Hash(String, JSON::Any)) 113 | ).should eq ( 114 | { 115 | "data" => { 116 | "value" => 3, 117 | }, 118 | } 119 | ).to_json 120 | end 121 | 122 | it "takes input array as variable" do 123 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 124 | mutation Mutation($obj : [MutationInputObject]) { 125 | value: array(io: $obj) 126 | } 127 | ), 128 | JSON.parse({"obj" => [{"value" => "123"}, {"value" => "321"}]}.to_json).raw.as(Hash(String, JSON::Any)) 129 | ).should eq ( 130 | { 131 | "data" => {"value" => ["123", "321"]}, 132 | } 133 | ).to_json 134 | end 135 | 136 | it "takes variable in object" do 137 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 138 | mutation Mutation($value1 : String, $value2 : String) { 139 | value: array(io: [{value: $value1}, {value: $value2}]) 140 | } 141 | ), 142 | JSON.parse({"value1" => "123", "value2" => "321"}.to_json).raw.as(Hash(String, JSON::Any)) 143 | ).should eq ( 144 | { 145 | "data" => {"value" => ["123", "321"]}, 146 | } 147 | ).to_json 148 | end 149 | 150 | it "returns error when null is passed to non-null" do 151 | GraphQL::Schema.new(StarWars::Query.new, MutationFixture::Mutation.new).execute(%( 152 | mutation Mutation { 153 | value: nonNull 154 | } 155 | )).should eq ( 156 | { 157 | "data" => {} of Nil => Nil, 158 | "errors" => [ 159 | {"message" => "missing required argument io", "path" => ["value"]}, 160 | ], 161 | } 162 | ).to_json 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/query_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe GraphQL::Introspection do 4 | it "returns expected introspection result" do 5 | got = GraphQL::Schema.new(QueryFixture::Query.new).execute(GraphQL::INTROSPECTION_QUERY) 6 | expected = JSON.parse({{ read_file "spec/fixtures/query_introspection.json" }}).to_json 7 | puts "\n====================\n#{got}\n====================" if got != expected 8 | got.should eq expected 9 | end 10 | 11 | it "resolves nested input object with various value types" do 12 | GraphQL::Schema.new(QueryFixture::Query.new).execute( 13 | %( 14 | { 15 | echoNestedInputObject(nestedInputObject: { 16 | object: { 17 | object: { 18 | object: { 19 | str: "ok", 20 | array: [ 21 | { 22 | str: $str, 23 | int: $int, 24 | float: $float, 25 | } 26 | ] 27 | } 28 | }, 29 | float: 11.111111 30 | }, 31 | int: 1, 32 | float: 1 33 | }) { 34 | object { 35 | object { 36 | object { 37 | str 38 | array { 39 | str 40 | int 41 | float 42 | id 43 | strReverse 44 | } 45 | } 46 | } 47 | float 48 | } 49 | int 50 | float 51 | } 52 | } 53 | ), 54 | {"str" => JSON::Any.new("foo"), "int" => JSON::Any.new(123_i64), "float" => JSON::Any.new(11_i64)} of String => JSON::Any 55 | ).should eq ( 56 | { 57 | "data" => { 58 | "echoNestedInputObject" => { 59 | "object" => { 60 | "object" => { 61 | "object" => { 62 | "str" => "ok", 63 | "array" => [ 64 | { 65 | "str" => "foo", 66 | "int" => 123, 67 | "float" => 11.0, 68 | "id" => "foo", 69 | "strReverse" => "oof", 70 | }, 71 | ], 72 | }, 73 | }, 74 | "float" => 11.111111, 75 | }, 76 | "int" => 1, 77 | "float" => 1.0, 78 | }, 79 | }, 80 | } 81 | ).to_json 82 | end 83 | 84 | it "resolves record field" do 85 | GraphQL::Schema.new(QueryFixture::Query.new).execute( 86 | %( 87 | query { 88 | record(value: "myvalue") { 89 | value 90 | } 91 | } 92 | ) 93 | ).should eq ( 94 | { 95 | "data" => { 96 | "record" => { 97 | "value" => "myvalue", 98 | }, 99 | }, 100 | 101 | } 102 | ).to_json 103 | end 104 | 105 | it "fails for mutations" do 106 | GraphQL::Schema.new(QueryFixture::Query.new).execute( 107 | %( 108 | mutation { 109 | foobar(baz: "123") 110 | } 111 | ) 112 | ).should eq ( 113 | { 114 | "errors" => [ 115 | {"message" => "mutation operations are not supported", 116 | "path" => [] of Nil, 117 | }, 118 | ], 119 | } 120 | ).to_json 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/schema_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe GraphQL::Schema do 4 | it "QueryFixture generates correct schema" do 5 | got = GraphQL::Schema.new(QueryFixture::Query.new).document.to_s.strip 6 | expected = {{ read_file("spec/fixtures/query.graphql") }}.strip 7 | puts "\n====================\n#{got}\n====================" if got != expected 8 | got.should eq expected 9 | end 10 | 11 | it "MutationFixture generates correct schema" do 12 | got = GraphQL::Schema.new(EmptyQueryFixture::Query.new, MutationFixture::Mutation.new).document.to_s.strip 13 | expected = {{ read_file("spec/fixtures/mutation.graphql") }}.strip 14 | puts "\n====================\n#{got}\n====================" if got != expected 15 | got.should eq expected 16 | end 17 | 18 | it "StarWars generates correct schema" do 19 | got = GraphQL::Schema.new(StarWars::Query.new).document.to_s.strip 20 | expected = {{ read_file("spec/fixtures/star_wars.graphql") }}.strip 21 | puts "\n====================\n#{got}\n====================" if got != expected 22 | got.should eq expected 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/graphql" 3 | require "./fixtures/*" 4 | -------------------------------------------------------------------------------- /spec/star_wars_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe StarWars::Query do 4 | it "Resolves human by id" do 5 | GraphQL::Schema.new(StarWars::Query.new).execute(%( 6 | { 7 | luke: human(id: "1000") { 8 | name 9 | } 10 | } 11 | )).should eq ( 12 | { 13 | "data" => { 14 | "luke" => { 15 | "name" => "Luke Skywalker", 16 | }, 17 | }, 18 | } 19 | ).to_json 20 | end 21 | 22 | it "Resolves fragments" do 23 | GraphQL::Schema.new(StarWars::Query.new).execute(%( 24 | query UseFragment { 25 | luke: human(id: "1000") { 26 | ...HumanFragment 27 | } 28 | leia: human(id: "1003") { 29 | ...HumanFragment 30 | } 31 | } 32 | fragment HumanFragment on Human { 33 | name 34 | homePlanet 35 | } 36 | )).should eq ( 37 | { 38 | "data" => { 39 | "luke" => { 40 | "name" => "Luke Skywalker", 41 | "homePlanet" => "Tatooine", 42 | }, 43 | "leia" => { 44 | "name" => "Leia Organa", 45 | "homePlanet" => "Alderaan", 46 | }, 47 | }, 48 | } 49 | ).to_json 50 | end 51 | 52 | it "Allows passing an io to render json to it" do 53 | result = String.build do |io| 54 | GraphQL::Schema.new(StarWars::Query.new).execute( 55 | io, 56 | %( 57 | { 58 | luke: human(id: "1000") { 59 | name 60 | } 61 | } 62 | ) 63 | ) 64 | end 65 | 66 | result.should eq ( 67 | { 68 | "data" => { 69 | "luke" => { 70 | "name" => "Luke Skywalker", 71 | }, 72 | }, 73 | } 74 | ).to_json 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /src/graphql.cr: -------------------------------------------------------------------------------- 1 | require "./graphql/*" 2 | -------------------------------------------------------------------------------- /src/graphql/annotations.cr: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | annotation Object 3 | end 4 | 5 | annotation InputObject 6 | end 7 | 8 | annotation Field 9 | end 10 | 11 | annotation Enum 12 | end 13 | 14 | annotation Scalar 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/graphql/context.cr: -------------------------------------------------------------------------------- 1 | require "./language" 2 | 3 | module GraphQL 4 | class Context 5 | property max_complexity : Int32? = nil 6 | property complexity = 0 7 | property fragments : Array(Language::FragmentDefinition) = [] of Language::FragmentDefinition 8 | property query_type : String = "" 9 | property mutation_type : String? = nil 10 | property document : Language::Document? 11 | 12 | # Return string message to be added to errors object or throw to bubble up 13 | def handle_exception(ex : ::Exception) : String? 14 | ex.message 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/graphql/document.cr: -------------------------------------------------------------------------------- 1 | require "./introspection" 2 | require "./scalars" 3 | 4 | module GraphQL::Document 5 | private macro _graphql_t(t, nilable) 6 | {% type = t.resolve %} 7 | {% unless nilable %} 8 | ::GraphQL::Language::NonNullType.new(of_type: 9 | {% end %} 10 | {% if type < ::Object && type.annotation(::GraphQL::Object) %} 11 | ::GraphQL::Language::TypeName.new(name: {{ type.annotation(::GraphQL::Object)["name"] || type.name.split("::").last }}) 12 | {% elsif type < ::Enum && type.annotation(::GraphQL::Enum) %} 13 | ::GraphQL::Language::TypeName.new(name: {{ type.annotation(::GraphQL::Enum)["name"] || type.name.split("::").last }}) 14 | {% elsif type < ::Object && type.annotation(::GraphQL::InputObject) %} 15 | ::GraphQL::Language::TypeName.new(name: {{ type.annotation(::GraphQL::InputObject)["name"] || type.name.split("::").last }}) 16 | {% elsif type < ::Object && type.annotation(::GraphQL::Scalar) %} 17 | ::GraphQL::Language::TypeName.new(name: {{ type.annotation(::GraphQL::Scalar)["name"] || type.name.split("::").last }}) 18 | {% elsif type == String %} 19 | ::GraphQL::Language::TypeName.new(name: "String") 20 | {% elsif type == Int32 %} 21 | ::GraphQL::Language::TypeName.new(name: "Int") 22 | {% elsif type < Float %} 23 | ::GraphQL::Language::TypeName.new(name: "Float") 24 | {% elsif type == Bool %} 25 | ::GraphQL::Language::TypeName.new(name: "Boolean") 26 | {% elsif type < Array %} 27 | {% inner = type.type_vars.find { |t| t != Nil } %} 28 | ::GraphQL::Language::ListType.new(of_type: _graphql_t({{ inner }}, {{ inner.nilable? }})) 29 | {% else %} 30 | {% raise "GraphQL: #{type} is not a GraphQL type" %} 31 | {% end %} 32 | {% unless nilable %} 33 | ) 34 | {% end %} 35 | end 36 | 37 | private macro _graphql_input_def(t, nilable, default, name, description) 38 | {% type = t.resolve %} 39 | ::GraphQL::Language::InputValueDefinition.new( 40 | name: {{ name }}, 41 | description: {{ description }}, 42 | type: (_graphql_t {{ type }}, {{ nilable }}), 43 | {% if type.annotation(::GraphQL::Enum) %} 44 | default_value: {{default}}.nil? ? nil : ::GraphQL::Language::AEnum.new(name: {{default}}.to_s), 45 | {% else %} 46 | default_value: {{default}}, 47 | {% end %} 48 | directives: [] of ::GraphQL::Language::Directive, 49 | ) 50 | end 51 | 52 | macro included 53 | macro finished 54 | {% verbatim do %} 55 | {% verbatim do %} 56 | # :nodoc: 57 | def _graphql_document 58 | {% begin %} 59 | {% 60 | objects = [@type, ::GraphQL::Introspection::Schema] 61 | enums = [] of TypeNode 62 | scalars = [::GraphQL::Scalars::String, ::GraphQL::Scalars::Boolean, ::GraphQL::Scalars::Float, ::GraphQL::Scalars::Int, ::GraphQL::Scalars::ID] of TypeNode 63 | 64 | (0..1000).each do |i| 65 | obj = objects[i] 66 | if obj 67 | vars = obj.instance_vars.select(&.annotation(::GraphQL::Field)) 68 | obj.ancestors.each do |ancestor| 69 | ancestor.instance_vars.select(&.annotation(::GraphQL::Field)).each do |var| 70 | vars << var 71 | end 72 | end 73 | 74 | vars.select(&.annotation(::GraphQL::Field)).each do |prop| 75 | prop.type.resolve.union_types.each do |type| 76 | if type.resolve.annotation(::GraphQL::InputObject) && !objects.includes?(type.resolve) && !(type.resolve < ::GraphQL::Context) 77 | objects << type.resolve 78 | end 79 | 80 | if type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(type.resolve) 81 | enums << type.resolve 82 | end 83 | 84 | if type.resolve.annotation(::GraphQL::Scalar) && !scalars.includes?(type.resolve) 85 | scalars << type.resolve 86 | end 87 | 88 | type.type_vars.each do |inner_type| 89 | if inner_type.resolve.annotation(::GraphQL::InputObject) && !objects.includes?(inner_type.resolve) && !(inner_type.resolve < ::GraphQL::Context) 90 | objects << inner_type.resolve 91 | end 92 | if inner_type.resolve.annotation(::GraphQL::Object) && !objects.includes?(inner_type.resolve) && !(inner_type.resolve < ::GraphQL::Context) 93 | objects << inner_type.resolve 94 | end 95 | if inner_type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(inner_type.resolve) 96 | enums << inner_type.resolve 97 | end 98 | end 99 | end 100 | end 101 | 102 | methods = obj.methods.select(&.annotation(::GraphQL::Field)) 103 | obj.ancestors.each do |ancestor| 104 | ancestor.methods.select(&.annotation(::GraphQL::Field)).each do |method| 105 | methods << method 106 | end 107 | end 108 | 109 | methods.each do |method| 110 | if method.return_type.is_a?(Nop) && !obj.annotation(::GraphQL::InputObject) 111 | raise "GraphQL: #{obj.name.id}##{method.name.id} must have a return type" 112 | end 113 | 114 | method.args.each do |arg| 115 | arg.restriction.resolve.union_types.each do |type| 116 | if type.resolve.annotation(::GraphQL::InputObject) && !objects.includes?(type.resolve) && !(type.resolve < ::GraphQL::Context) 117 | objects << type.resolve 118 | end 119 | 120 | if type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(type.resolve) 121 | enums << type.resolve 122 | end 123 | 124 | if type.resolve.annotation(::GraphQL::Scalar) && !scalars.includes?(type.resolve) 125 | scalars << type.resolve 126 | end 127 | 128 | type.type_vars.each do |inner_type| 129 | if inner_type.resolve.annotation(::GraphQL::InputObject) && !objects.includes?(inner_type.resolve) && !(inner_type.resolve < ::GraphQL::Context) 130 | objects << inner_type.resolve 131 | end 132 | if inner_type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(inner_type.resolve) 133 | enums << inner_type.resolve 134 | end 135 | end 136 | end 137 | end 138 | 139 | if obj.annotation(::GraphQL::Object) 140 | method.return_type.types.each do |type| 141 | if type.resolve < Array 142 | type.resolve.type_vars.each do |inner_type| 143 | if (inner_type.resolve.annotation(::GraphQL::Object) || inner_type.resolve.annotation(::GraphQL::InputObject)) && !objects.includes?(inner_type.resolve) 144 | objects << inner_type.resolve 145 | end 146 | 147 | if inner_type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(inner_type.resolve) 148 | enums << inner_type.resolve 149 | end 150 | if inner_type.resolve.annotation(::GraphQL::Scalar) && !scalars.includes?(inner_type.resolve) 151 | scalars << inner_type.resolve 152 | end 153 | end 154 | end 155 | 156 | if (type.resolve.annotation(::GraphQL::Object) || type.resolve.annotation(::GraphQL::InputObject)) && !objects.includes?(type.resolve) && !(type.resolve < ::GraphQL::Context) 157 | objects << type.resolve 158 | end 159 | 160 | if type.resolve.annotation(::GraphQL::Enum) && !enums.includes?(type.resolve) 161 | enums << type.resolve 162 | end 163 | 164 | if type.resolve.annotation(::GraphQL::Scalar) && !scalars.includes?(type.resolve) 165 | scalars << type.resolve 166 | end 167 | end 168 | end 169 | end 170 | end 171 | end 172 | 173 | raise "GraphQL: document object limit reached" unless objects.size < 1000 174 | %} 175 | 176 | %definitions = [] of ::GraphQL::Language::TypeDefinition 177 | 178 | {% for object in objects %} 179 | %fields = [] of ::GraphQL::Language::FieldDefinition 180 | 181 | {% 182 | vars = object.instance_vars.select(&.annotation(::GraphQL::Field)) 183 | object.ancestors.each do |ancestor| 184 | ancestor.instance_vars.select(&.annotation(::GraphQL::Field)).each do |var| 185 | vars << var 186 | end 187 | end 188 | %} 189 | 190 | {% for var in vars %} 191 | %directives = [] of ::GraphQL::Language::Directive 192 | {% if var.annotation(::GraphQL::Field)["deprecated"] %} 193 | %directives << ::GraphQL::Language::Directive.new( 194 | name: "deprecated", 195 | arguments: [GraphQL::Language::Argument.new("reason", {{var.annotation(::GraphQL::Field)["deprecated"]}})] 196 | ) 197 | {% end %} 198 | %fields << ::GraphQL::Language::FieldDefinition.new( 199 | name: {{ var.annotation(::GraphQL::Field)["name"] || var.name.id.stringify.camelcase(lower: true) }}, 200 | arguments: [] of ::GraphQL::Language::InputValueDefinition, 201 | type: (_graphql_t {{ var.type.union_types.find { |t| t != Nil } }}, {{ var.type.nilable? }}), 202 | directives: %directives, 203 | description: {{ var.annotation(::GraphQL::Field)["description"] }}, 204 | ) 205 | {% end %} 206 | 207 | {% 208 | methods = object.methods.select(&.annotation(::GraphQL::Field)) 209 | object.ancestors.each do |ancestor| 210 | ancestor.methods.select(&.annotation(::GraphQL::Field)).each do |method| 211 | methods << method 212 | end 213 | end 214 | %} 215 | 216 | {% for method in methods %} 217 | %input_values = [] of ::GraphQL::Language::InputValueDefinition 218 | {% for arg in method.args %} 219 | {% unless arg.restriction.resolve <= ::GraphQL::Context %} 220 | {% 221 | ann_args = method.annotation(::GraphQL::Field)["arguments"] 222 | ann_arg = ann_args && ann_args[arg.name.id] 223 | %} 224 | %input_values << (_graphql_input_def( 225 | {{ arg.restriction.resolve.union_types.find { |t| t != Nil } }}, 226 | {{ arg.restriction.resolve.nilable? }}, 227 | {{ arg.default_value.is_a?(Nop) ? nil : arg.default_value }}, 228 | {{ ann_arg && ann_arg["name"] || arg.name.id.stringify.camelcase(lower: true) }}, 229 | {{ ann_arg && ann_arg["description"] || nil }}, 230 | )) 231 | {% end %} 232 | {% end %} 233 | 234 | {% if !object.annotation(::GraphQL::InputObject) %} 235 | {% type = method.return_type.resolve %} 236 | {% if !(type < ::GraphQL::Context) && type != Nil %} 237 | %directives = [] of ::GraphQL::Language::Directive 238 | {% if method.annotation(::GraphQL::Field)["deprecated"] %} 239 | %directives << ::GraphQL::Language::Directive.new( 240 | name: "deprecated", 241 | arguments: [GraphQL::Language::Argument.new("reason", {{method.annotation(::GraphQL::Field)["deprecated"]}})] 242 | ) 243 | {% end %} 244 | %fields << ::GraphQL::Language::FieldDefinition.new( 245 | name: {{ method.annotation(::GraphQL::Field)["name"] || method.name.id.stringify.camelcase(lower: true) }}, 246 | arguments: %input_values.sort{|a, b| a.name <=> b.name }, 247 | type: (_graphql_t {{ type.union_types.find { |t| t != Nil } }}, {{ type.nilable? }}), 248 | directives: %directives, 249 | description: {{ method.annotation(::GraphQL::Field)["description"] }}, 250 | ) 251 | {% end %} 252 | {% end %} 253 | {% end %} 254 | 255 | {% if object.annotation(::GraphQL::Object) %} 256 | %definitions << ::GraphQL::Language::ObjectTypeDefinition.new( 257 | name: {{ object.annotation(::GraphQL::Object)["name"] || object.name.split("::").last }}, 258 | fields: %fields.sort{|a, b| a.name <=> b.name }, 259 | interfaces: [] of String?, 260 | directives: [] of ::GraphQL::Language::Directive, 261 | description: {{ object.annotation(::GraphQL::Object)["description"] }}, 262 | ) 263 | {% elsif object.annotation(::GraphQL::InputObject) %} 264 | %definitions << ::GraphQL::Language::InputObjectTypeDefinition.new( 265 | name: {{ object.annotation(::GraphQL::InputObject)["name"] || object.name.split("::").last }}, 266 | fields: %input_values, 267 | directives: [] of ::GraphQL::Language::Directive, 268 | description: {{ object.annotation(::GraphQL::InputObject)["description"] }}, 269 | ) 270 | {% else %} 271 | {% raise "GraphQL: unknown object type ??? #{object.name}" %} 272 | {% end %} 273 | {% end %} 274 | 275 | {% for e_num in enums %} 276 | %definitions << ::GraphQL::Language::EnumTypeDefinition.new( 277 | name: {{ e_num.annotation(::GraphQL::Enum)["name"] || e_num.name.split("::").last }}, 278 | description: {{ e_num.annotation(::GraphQL::Enum)["description"] }}, 279 | fvalues: ([ 280 | {% for constant in e_num.resolve.constants %} 281 | ::GraphQL::Language::EnumValueDefinition.new( 282 | name: {{ constant.stringify }}, 283 | directives: [] of ::GraphQL::Language::Directive, 284 | selection: nil, 285 | description: nil, # TODO 286 | ), 287 | {% end %} 288 | ] of ::GraphQL::Language::EnumValueDefinition).sort {|a, b| a.name <=> b.name }, 289 | directives: [] of ::GraphQL::Language::Directive, 290 | ) 291 | {% end %} 292 | 293 | {% for scalar in scalars %} 294 | %definitions << ::GraphQL::Language::ScalarTypeDefinition.new( 295 | name: {{ scalar.annotation(::GraphQL::Scalar)["name"] || scalar.name.split("::").last }}, 296 | description: {{ scalar.annotation(::GraphQL::Scalar)["description"] }}, 297 | directives: [] of ::GraphQL::Language::Directive 298 | ) 299 | {% end %} 300 | 301 | ::GraphQL::Language::Document.new(%definitions.sort { |a, b| a.name <=> b.name }) 302 | {% end %} 303 | end 304 | {% end %} 305 | {% end %} 306 | end 307 | end 308 | end 309 | -------------------------------------------------------------------------------- /src/graphql/error.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module GraphQL 4 | class Error 5 | include JSON::Serializable 6 | 7 | @[JSON::Field] 8 | property message : String 9 | 10 | @[JSON::Field] 11 | property path : Array(String | Int32) 12 | 13 | def initialize(@message, path : String) 14 | @path = [path] of String | Int32 15 | end 16 | 17 | def initialize(@message, @path : Array(String | Int32)) 18 | end 19 | 20 | def with_path(path : String | Int32) 21 | @path.unshift path 22 | self 23 | end 24 | end 25 | 26 | abstract class Exception < ::Exception 27 | end 28 | 29 | class TypeError < Exception 30 | end 31 | 32 | class ParserError < Exception 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/graphql/input_object_type.cr: -------------------------------------------------------------------------------- 1 | require "./internal/convert_value" 2 | 3 | module GraphQL::InputObjectType 4 | macro included 5 | macro finished 6 | {% verbatim do %} 7 | # :nodoc: 8 | def self._graphql_new(input_object : ::GraphQL::Language::InputObject) 9 | {% method = @type.methods.find(&.annotation(::GraphQL::Field)) %} 10 | self.new( 11 | {% for arg in method.args %} 12 | {{arg.name}}: begin 13 | fa = input_object.arguments.find { |i| i.name == {{ arg.name.stringify.camelcase(lower: true) }} } 14 | if fa.nil? || fa.value.nil? 15 | {% if !(arg.default_value.is_a? Nop) %} 16 | {{arg.default_value}} 17 | {% elsif arg.restriction.resolve.nilable? %} 18 | nil 19 | {% else %} 20 | raise ::GraphQL::TypeError.new("missing required input value {{ arg.name.camelcase(lower: true) }}") 21 | {% end %} 22 | else 23 | ::GraphQL::Internal.convert_value {{ arg.restriction.resolve.union_types.find { |t| t != Nil } }}, fa.value, {{ arg.name.camelcase(lower: true) }} 24 | end 25 | end, 26 | {% end %} 27 | ) 28 | end 29 | {% end %} 30 | end 31 | end 32 | end 33 | 34 | module GraphQL 35 | abstract class BaseInputObject 36 | macro inherited 37 | include GraphQL::InputObjectType 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/graphql/internal/convert_value.cr: -------------------------------------------------------------------------------- 1 | module GraphQL::Internal 2 | macro convert_value(t, value, name) 3 | {% type = t.resolve %} 4 | case %value = {{value}} 5 | when {{type}} 6 | %value 7 | {% if type == Float64 %} 8 | when Int32 9 | %value.to_f64.as({{type}}) 10 | {% elsif type.annotation(::GraphQL::Enum) %} 11 | when ::GraphQL::Language::AEnum 12 | {{type}}.parse(%value.to_value) 13 | when String 14 | {{type}}.parse(%value) 15 | {% elsif type.annotation(::GraphQL::InputObject) %} 16 | when ::GraphQL::Language::InputObject 17 | {{type}}._graphql_new(%value.as(::GraphQL::Language::InputObject)) 18 | {% elsif type < ::GraphQL::ScalarType %} 19 | when String, Int32, Float64 20 | {{type}}.from_json(%value.to_json) 21 | {% elsif type < Array %} 22 | when Array 23 | {% inner_type = type.type_vars.find { |t| t != Nil } %} 24 | %value.map do |%v| 25 | ::GraphQL::Internal.convert_value {{ inner_type }}, %v, name 26 | end 27 | {% end %} 28 | else 29 | raise ::GraphQL::TypeError.new("bad type for argument {{ name }}") 30 | end.as({{type}}) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/graphql/introspection.cr: -------------------------------------------------------------------------------- 1 | require "./annotations" 2 | require "./object_type" 3 | require "./query_type" 4 | 5 | module GraphQL 6 | module Introspection 7 | @[GraphQL::Object(name: "__Schema")] 8 | class Schema 9 | include GraphQL::ObjectType 10 | 11 | @document : Language::Document 12 | @query_type : String 13 | @mutation_type : String? 14 | 15 | def initialize(@document, @query_type, @mutation_type) 16 | end 17 | 18 | @[GraphQL::Field] 19 | def types : Array(GraphQL::Introspection::Type) 20 | @document.definitions.select(Language::TypeDefinition).map { |d| Type.new @document, d.as(Language::TypeDefinition) } 21 | end 22 | 23 | @[GraphQL::Field] 24 | def query_type : GraphQL::Introspection::Type 25 | Type.new @document, @document.definitions.find! { |d| 26 | d.is_a?(Language::TypeDefinition) && d.name == @query_type 27 | }.as(Language::TypeDefinition) 28 | end 29 | 30 | @[GraphQL::Field] 31 | def mutation_type : GraphQL::Introspection::Type? 32 | if mt = @mutation_type 33 | Type.new @document, @document.definitions.find! { |d| 34 | d.is_a?(Language::TypeDefinition) && d.name == mt 35 | }.as(Language::TypeDefinition) 36 | else 37 | nil 38 | end 39 | end 40 | 41 | @[GraphQL::Field] 42 | def subscription_type : GraphQL::Introspection::Type? 43 | nil 44 | end 45 | 46 | @[GraphQL::Field] 47 | def directives : Array(GraphQL::Introspection::Directive) 48 | [ 49 | GraphQL::Introspection::Directive.new( 50 | @document, 51 | Language::DirectiveDefinition.new( 52 | name: "skip", 53 | description: nil, 54 | locations: [ 55 | DirectiveLocation::FIELD.to_s, 56 | DirectiveLocation::FRAGMENT_SPREAD.to_s, 57 | DirectiveLocation::INLINE_FRAGMENT.to_s, 58 | ], 59 | arguments: [ 60 | Language::InputValueDefinition.new( 61 | name: "if", 62 | type: Language::NonNullType.new(of_type: Language::TypeName.new(name: "Boolean")), 63 | default_value: nil, 64 | directives: [] of GraphQL::Language::Directive, 65 | description: nil, 66 | ), 67 | ] 68 | ) 69 | ), 70 | GraphQL::Introspection::Directive.new( 71 | @document, 72 | Language::DirectiveDefinition.new( 73 | name: "include", 74 | description: nil, 75 | locations: [ 76 | DirectiveLocation::FIELD.to_s, 77 | DirectiveLocation::FRAGMENT_SPREAD.to_s, 78 | DirectiveLocation::INLINE_FRAGMENT.to_s, 79 | ], 80 | arguments: [ 81 | Language::InputValueDefinition.new( 82 | name: "if", 83 | type: Language::NonNullType.new(of_type: Language::TypeName.new(name: "Boolean")), 84 | default_value: nil, 85 | directives: [] of GraphQL::Language::Directive, 86 | description: nil, 87 | ), 88 | ] 89 | ) 90 | ), 91 | GraphQL::Introspection::Directive.new( 92 | @document, 93 | Language::DirectiveDefinition.new( 94 | name: "deprecated", 95 | description: nil, 96 | locations: [ 97 | DirectiveLocation::FIELD_DEFINITION.to_s, 98 | DirectiveLocation::ENUM_VALUE.to_s, 99 | ], 100 | arguments: [ 101 | Language::InputValueDefinition.new( 102 | name: "reason", 103 | type: Language::TypeName.new(name: "String"), 104 | default_value: nil, 105 | directives: [] of GraphQL::Language::Directive, 106 | description: nil, 107 | ), 108 | ] 109 | ) 110 | ), 111 | ] of GraphQL::Introspection::Directive 112 | end 113 | end 114 | 115 | @[GraphQL::Object(name: "__Type")] 116 | class Type 117 | include GraphQL::ObjectType 118 | 119 | @document : Language::Document 120 | @definition : Language::TypeDefinition | Language::WrapperType 121 | 122 | def self.from_ast(document : Language::Document, type : Language::ASTNode) 123 | case type 124 | when Language::TypeName 125 | self.new(document, document.definitions.find! { |d| d.is_a? Language::TypeDefinition && d.name == type.name }.as(Language::TypeDefinition)) 126 | when Language::TypeDefinition, Language::WrapperType 127 | self.new(document, type) 128 | else 129 | raise GraphQL::TypeError.new("cannot create type from #{type}") 130 | end 131 | end 132 | 133 | def initialize(@document, @definition : Language::TypeDefinition | Language::WrapperType) 134 | end 135 | 136 | @[GraphQL::Field] 137 | def kind : GraphQL::Introspection::TypeKind 138 | case @definition 139 | when Language::ObjectTypeDefinition 140 | TypeKind::OBJECT 141 | when Language::InputObjectTypeDefinition 142 | TypeKind::INPUT_OBJECT 143 | when Language::ScalarTypeDefinition 144 | TypeKind::SCALAR 145 | when Language::EnumTypeDefinition 146 | TypeKind::ENUM 147 | when Language::InterfaceTypeDefinition 148 | TypeKind::INTERFACE 149 | when Language::UnionTypeDefinition 150 | TypeKind::UNION 151 | when Language::NonNullType 152 | TypeKind::NON_NULL 153 | when Language::ListType 154 | TypeKind::LIST 155 | else 156 | raise GraphQL::TypeError.new("could not match any type") 157 | end 158 | end 159 | 160 | @[GraphQL::Field] 161 | def name : String? 162 | case definition = @definition 163 | when Language::ObjectTypeDefinition 164 | definition.name 165 | when Language::InputObjectTypeDefinition 166 | definition.name 167 | when Language::ScalarTypeDefinition 168 | definition.name 169 | when Language::EnumTypeDefinition 170 | definition.name 171 | when Language::InterfaceTypeDefinition 172 | definition.name 173 | when Language::UnionTypeDefinition 174 | definition.name 175 | else 176 | nil 177 | end 178 | end 179 | 180 | @[GraphQL::Field] 181 | def description : String? 182 | case definition = @definition 183 | when Language::ObjectTypeDefinition 184 | definition.description 185 | when Language::InputObjectTypeDefinition 186 | definition.description 187 | when Language::ScalarTypeDefinition 188 | definition.description 189 | when Language::EnumTypeDefinition 190 | definition.description 191 | when Language::InterfaceTypeDefinition 192 | definition.description 193 | when Language::UnionTypeDefinition 194 | definition.description 195 | when Language::NonNullType 196 | nil 197 | when Language::ListType 198 | nil 199 | end 200 | end 201 | 202 | # OBJECT and INTERFACE only 203 | @[GraphQL::Field] 204 | def fields(include_deprecated : Bool = false) : Array(GraphQL::Introspection::Field)? 205 | case definition = @definition 206 | when Language::ObjectTypeDefinition 207 | definition.fields.select { |f| 208 | if include_deprecated 209 | true 210 | else 211 | f.directives.find { |d| d.name == "deprecated" }.nil? 212 | end 213 | }.map { |f| 214 | GraphQL::Introspection::Field.new(@document, f.as(Language::FieldDefinition)) 215 | } 216 | when Language::InterfaceTypeDefinition # why can't we put this above? 217 | definition.fields.map { |f| GraphQL::Introspection::Field.new(@document, f.as(Language::FieldDefinition)) } 218 | else 219 | nil 220 | end 221 | end 222 | 223 | # OBJECT only 224 | @[GraphQL::Field] 225 | def interfaces : Array(GraphQL::Introspection::Type)? 226 | case @definition 227 | when Language::ObjectTypeDefinition 228 | [] of GraphQL::Introspection::Type 229 | else 230 | nil 231 | end 232 | end 233 | 234 | # INTERFACE and UNION only 235 | @[GraphQL::Field] 236 | def possible_types : Array(GraphQL::Introspection::Type)? 237 | case @definition 238 | when Language::InterfaceTypeDefinition, Language::UnionTypeDefinition 239 | [] of GraphQL::Introspection::Type 240 | else 241 | nil 242 | end 243 | end 244 | 245 | # ENUM only 246 | @[GraphQL::Field] 247 | def enum_values(include_deprecated : Bool = false) : Array(GraphQL::Introspection::EnumValue)? 248 | case definition = @definition 249 | when Language::EnumTypeDefinition 250 | definition.fvalues.select { |v| 251 | if include_deprecated 252 | true 253 | else 254 | v.directives.find { |d| d.name == "deprecated" }.nil? 255 | end 256 | }.map { |v| EnumValue.new(@document, v) } 257 | else 258 | nil 259 | end 260 | end 261 | 262 | # INPUT_OBJECT only 263 | @[GraphQL::Field] 264 | def input_fields : Array(GraphQL::Introspection::InputValue)? 265 | case definition = @definition 266 | when Language::InputObjectTypeDefinition 267 | definition.fields.map { |f| GraphQL::Introspection::InputValue.new(@document, f.as(Language::InputValueDefinition)) } 268 | else 269 | nil 270 | end 271 | end 272 | 273 | # NON_NULL and LIST only 274 | @[GraphQL::Field] 275 | def of_type : GraphQL::Introspection::Type? 276 | case definition = @definition 277 | when Language::NonNullType, Language::ListType 278 | Type.from_ast(@document, definition.of_type) 279 | else 280 | nil 281 | end 282 | end 283 | end 284 | 285 | @[GraphQL::Object(name: "__Field")] 286 | class Field 287 | include GraphQL::ObjectType 288 | 289 | @document : GraphQL::Language::Document 290 | @definition : GraphQL::Language::FieldDefinition 291 | 292 | def initialize(@document, @definition) 293 | end 294 | 295 | # NON_NULL and LIST only 296 | @[GraphQL::Field] 297 | def name : String 298 | @definition.name 299 | end 300 | 301 | @[GraphQL::Field] 302 | def description : String? 303 | @definition.description 304 | end 305 | 306 | @[GraphQL::Field] 307 | def args : Array(GraphQL::Introspection::InputValue) 308 | @definition.arguments.map { |m| InputValue.new(@document, m) } 309 | end 310 | 311 | @[GraphQL::Field] 312 | def type : GraphQL::Introspection::Type 313 | GraphQL::Introspection::Type.from_ast(@document, @definition.type) 314 | end 315 | 316 | @[GraphQL::Field] 317 | def is_deprecated : Bool 318 | !@definition.directives.find { |d| d.name == "deprecated" }.nil? 319 | end 320 | 321 | @[GraphQL::Field] 322 | def deprecation_reason : String? 323 | if directive = @definition.directives.find { |d| d.name == "deprecated" } 324 | if argument = directive.arguments.find { |d| d.name == "reason" } 325 | argument.value.as(String) 326 | end 327 | end 328 | end 329 | end 330 | 331 | @[GraphQL::Object(name: "__InputValue")] 332 | class InputValue 333 | include GraphQL::ObjectType 334 | 335 | @document : Language::Document 336 | @definition : Language::InputValueDefinition 337 | 338 | def initialize(@document, @definition) 339 | end 340 | 341 | @[GraphQL::Field] 342 | def name : String 343 | @definition.name 344 | end 345 | 346 | @[GraphQL::Field] 347 | def description : String? 348 | @definition.description 349 | end 350 | 351 | @[GraphQL::Field] 352 | def type : GraphQL::Introspection::Type 353 | GraphQL::Introspection::Type.from_ast(@document, @definition.type) 354 | end 355 | 356 | @[GraphQL::Field] 357 | def default_value : String? 358 | Language::Generation.generate(@definition.default_value) unless @definition.default_value.nil? 359 | end 360 | end 361 | 362 | @[GraphQL::Object(name: "__EnumValue")] 363 | class EnumValue 364 | include GraphQL::ObjectType 365 | 366 | @document : Language::Document 367 | @definition : Language::EnumValueDefinition 368 | 369 | def initialize(@document, @definition) 370 | end 371 | 372 | @[GraphQL::Field] 373 | def name : String 374 | @definition.name 375 | end 376 | 377 | @[GraphQL::Field] 378 | def description : String? 379 | @definition.description 380 | end 381 | 382 | @[GraphQL::Field] 383 | def is_deprecated : Bool 384 | !@definition.directives.find { |d| d.name == "deprecated" }.nil? 385 | end 386 | 387 | @[GraphQL::Field] 388 | def deprecation_reason : String? 389 | if directive = @definition.directives.find { |d| d.name == "deprecated" } 390 | if argument = directive.arguments.find { |d| d.name == "reason" } 391 | argument.value.as(String) 392 | end 393 | end 394 | end 395 | end 396 | 397 | @[GraphQL::Object(name: "__Directive")] 398 | class Directive 399 | include GraphQL::ObjectType 400 | 401 | @document : Language::Document 402 | @definition : Language::DirectiveDefinition 403 | 404 | def initialize(@document, @definition) 405 | end 406 | 407 | @[GraphQL::Field] 408 | def name : String 409 | @definition.name 410 | end 411 | 412 | @[GraphQL::Field] 413 | def description : String? 414 | @definition.description 415 | end 416 | 417 | @[GraphQL::Field] 418 | def locations : Array(String) 419 | @definition.locations 420 | end 421 | 422 | @[GraphQL::Field] 423 | def args : Array(GraphQL::Introspection::InputValue) 424 | @definition.arguments.map { |a| InputValue.new(@document, a) } 425 | end 426 | end 427 | 428 | @[GraphQL::Enum(name: "__TypeKind")] 429 | enum TypeKind 430 | SCALAR 431 | OBJECT 432 | INTERFACE 433 | UNION 434 | ENUM 435 | INPUT_OBJECT 436 | LIST 437 | NON_NULL 438 | end 439 | 440 | @[GraphQL::Enum(name: "__DirectiveLocation")] 441 | enum DirectiveLocation 442 | QUERY 443 | MUTATION 444 | SUBSCRIPTION 445 | FIELD 446 | FRAGMENT_DEFINITION 447 | FRAGMENT_SPREAD 448 | INLINE_FRAGMENT 449 | SCHEMA 450 | SCALAR 451 | OBJECT 452 | FIELD_DEFINITION 453 | ARGUMENT_DEFINITION 454 | INTERFACE 455 | UNION 456 | ENUM 457 | ENUM_VALUE 458 | INPUT_OBJECT 459 | INPUT_FIELD_DEFINITION 460 | end 461 | end 462 | end 463 | -------------------------------------------------------------------------------- /src/graphql/introspection_query.cr: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | INTROSPECTION_QUERY = %( 3 | query IntrospectionQuery { 4 | __schema { 5 | queryType { name } 6 | mutationType { name } 7 | subscriptionType { name } 8 | types { 9 | ...FullType 10 | } 11 | directives { 12 | name 13 | description 14 | locations 15 | args { 16 | ...InputValue 17 | } 18 | } 19 | } 20 | } 21 | fragment FullType on __Type { 22 | kind 23 | name 24 | description 25 | fields(includeDeprecated: true) { 26 | name 27 | description 28 | args { 29 | ...InputValue 30 | } 31 | type { 32 | ...TypeRef 33 | } 34 | isDeprecated 35 | deprecationReason 36 | } 37 | inputFields { 38 | ...InputValue 39 | } 40 | interfaces { 41 | ...TypeRef 42 | } 43 | enumValues(includeDeprecated: true) { 44 | name 45 | description 46 | isDeprecated 47 | deprecationReason 48 | } 49 | possibleTypes { 50 | ...TypeRef 51 | } 52 | } 53 | fragment InputValue on __InputValue { 54 | name 55 | description 56 | type { ...TypeRef } 57 | defaultValue 58 | } 59 | fragment TypeRef on __Type { 60 | kind 61 | name 62 | ofType { 63 | kind 64 | name 65 | ofType { 66 | kind 67 | name 68 | ofType { 69 | kind 70 | name 71 | ofType { 72 | kind 73 | name 74 | ofType { 75 | kind 76 | name 77 | ofType { 78 | kind 79 | name 80 | ofType { 81 | kind 82 | name 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | ) 92 | end 93 | -------------------------------------------------------------------------------- /src/graphql/language.cr: -------------------------------------------------------------------------------- 1 | require "./language/lexer" 2 | require "./language/nodes" 3 | require "./language/parser" 4 | require "./language/generation" 5 | 6 | module GraphQL::Language 7 | # Parse a query string and return the Document 8 | def self.parse(query_string, options = NamedTuple.new) : GraphQL::Language::Document 9 | GraphQL::Language::Parser.new( 10 | GraphQL::Language::Lexer.new 11 | ).parse(query_string) 12 | rescue e 13 | raise e 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/graphql/language/ast.cr: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Language 3 | abstract class ASTNode 4 | macro values(args) 5 | property {{args.map { |k, v| "#{k} : #{v}" }.join(",").id}} 6 | 7 | def_equals_and_hash {{args.keys}} 8 | 9 | def initialize({{args.keys.join(",").id}}, **rest) 10 | {% 11 | assignments = args.map do |k, v| 12 | if v.is_a?(Generic) && v.name.id == "Array" 13 | type = v.type_vars.first.id 14 | "@#{k.id} = #{k.id}.map(&.as(#{type}))" 15 | else 16 | "@#{k.id} = #{k.id}" 17 | end 18 | end 19 | %} 20 | 21 | {{assignments.join("\n").id}} 22 | 23 | super(**rest) 24 | end 25 | end 26 | 27 | def children 28 | [] of ASTNode 29 | end 30 | 31 | def visit(block : ASTNode -> _) 32 | children.each do |c| 33 | case val = c 34 | when Array 35 | val.each(&.visit(block)) 36 | when nil 37 | else 38 | val.visit(block) 39 | end 40 | end 41 | 42 | block.call(self) 43 | end 44 | end # ASTNode 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /src/graphql/language/generation.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module GraphQL 4 | module Language 5 | # Exposes {.generate}, which turns AST nodes back into query strings. 6 | # Turn an AST node back into a string. 7 | # 8 | # @example Turning a document into a query 9 | # document = GraphQL.parse(query_string) 10 | # Generation.generate(document) 11 | # # => "{ ... }" 12 | # 13 | # @param node [ASTNode] an AST node to recursively stringify 14 | # @param indent [String] Whitespace to add to each printed node 15 | # @return [String] Valid GraphQL for `node` 16 | module Generation 17 | def self.generate(node : Document, indent : String = "") 18 | node.definitions.map { |d| generate(d) }.join("\n\n") 19 | end 20 | 21 | def self.generate(node : Argument, indent : String = "") 22 | "#{node.name}: #{generate(node.value)}" 23 | end 24 | 25 | def self.generate(node : Directive, indent : String = "") 26 | out = "@#{node.name}" 27 | out += "(#{node.arguments.map { |a| generate(a).as(String) }.join(", ")})" unless node.arguments.empty? 28 | out 29 | end 30 | 31 | def self.generate(node : AEnum, indent : String = "") 32 | "#{node.name}" 33 | end 34 | 35 | def self.generate(node : NullValue, indent : String = "") 36 | "null" 37 | end 38 | 39 | def self.generate(node : Field, indent : String = "") 40 | out = "" 41 | out += "#{node._alias}: " if node._alias 42 | out += "#{node.name}" 43 | out += "(#{node.arguments.map { |a| generate(a).as(String) }.join(", ")})" unless node.arguments.empty? 44 | out += generate_directives(node.directives) 45 | out += generate_selections(node.selections, indent: indent) 46 | out 47 | end 48 | 49 | def self.generate(node : FragmentDefinition, indent : String = "") 50 | out = "#{indent}fragment #{node.name}" 51 | if node.type 52 | out += " on #{generate(node.type)}" 53 | end 54 | out += generate_directives(node.directives) 55 | out += generate_selections(node.selections, indent: indent) 56 | out 57 | end 58 | 59 | def self.generate(node : FragmentSpread, indent : String = "") 60 | out = "#{indent}...#{node.name}" 61 | unless node.directives.empty? 62 | out += " " + node.directives.map { |d| generate(d).as(String) }.join(" ") 63 | end 64 | end 65 | 66 | def self.generate(node : InlineFragment, indent : String = "") 67 | out = "#{indent}..." 68 | if node.type 69 | out += " on #{generate(node.type)}" 70 | end 71 | out += generate_directives(node.directives) 72 | out += generate_selections(node.selections, indent: indent) 73 | out 74 | end 75 | 76 | def self.generate(node : InputObject, indent : String = "") 77 | generate(node.to_h) 78 | end 79 | 80 | def self.generate(node : ListType, indent : String = "") 81 | "[#{generate(node.of_type)}]" 82 | end 83 | 84 | def self.generate(node : NonNullType, indent : String = "") 85 | "#{generate(node.of_type)}!" 86 | end 87 | 88 | def self.generate(node : OperationDefinition, indent : String = "") 89 | out = "#{indent}#{node.operation_type}" 90 | if node.name 91 | out += " #{node.name}" 92 | end 93 | out += "(#{node.variables.map { |v| generate(v) }.join(", ")})" unless node.variables.empty? 94 | out += generate_directives(node.directives) 95 | out += generate_selections(node.selections, indent: indent) 96 | out 97 | end 98 | 99 | def self.generate(node : TypeName, indent : String = "") 100 | "#{node.name}" 101 | end 102 | 103 | def self.generate(node : VariableDefinition) 104 | out = "$#{node.name}: #{generate(node.type)}" 105 | unless node.default_value.nil? 106 | out += " = #{generate(node.default_value)}" 107 | end 108 | out 109 | end 110 | 111 | def self.generate(node : VariableIdentifier, indent : String = "") 112 | "$#{node.name}" 113 | end 114 | 115 | def self.generate(node : SchemaDefinition, indent : String = "") 116 | if (node.query.nil? || node.query == "Query") && 117 | (node.mutation.nil? || node.mutation == "Mutation") && 118 | (node.subscription.nil? || node.subscription == "Subscription") 119 | return "" 120 | end 121 | out = "schema {\n" 122 | out += " query: #{node.query}\n" if node.query 123 | out += " mutation: #{node.mutation}\n" if node.mutation 124 | out += " subscription: #{node.subscription}\n" if node.subscription 125 | out += "}" 126 | end 127 | 128 | def self.generate(node : ScalarTypeDefinition, indent : String = "") 129 | out = generate_description(node) 130 | out += "scalar #{node.name}" 131 | out += generate_directives(node.directives) 132 | end 133 | 134 | def self.generate(node : ObjectTypeDefinition, indent : String = "") 135 | out = generate_description(node) 136 | out += "type #{node.name}" 137 | out += generate_directives(node.directives) 138 | out += " implements " + node.interfaces.map { |i| i.as(String) }.join(", ") unless node.interfaces.empty? 139 | out += generate_field_definitions(node.fields) 140 | end 141 | 142 | def self.generate(node : InputValueDefinition, indent : String = "") 143 | out = generate_description(node, indent: indent) 144 | out += "#{indent}#{node.name}: #{generate(node.type)}" 145 | out += " = #{generate(node.default_value)}" unless node.default_value.nil? 146 | out += generate_directives(node.directives) 147 | end 148 | 149 | def self.generate(node : FieldDefinition, indent : String = "") 150 | out = indent + node.name.dup 151 | unless node.arguments.empty? 152 | with_descriptions = !node.arguments.find { |arg| !arg.description.nil? }.nil? 153 | out += "(" 154 | args = node.arguments.map { |arg| 155 | if with_descriptions 156 | "\n" + generate(arg, indent: indent*2).as(String) 157 | else 158 | generate(arg).as(String) 159 | end 160 | } 161 | out += with_descriptions ? args.join("") : args.join(", ") 162 | out += "\n#{indent}" if with_descriptions 163 | out += ")" 164 | end 165 | out += ": #{generate(node.type)}" 166 | out += generate_directives(node.directives) 167 | end 168 | 169 | def self.generate(node : InterfaceTypeDefinition, indent : String = "") 170 | out = generate_description(node) 171 | out += "interface #{node.name}" 172 | out += generate_directives(node.directives) 173 | out += generate_field_definitions(node.fields) 174 | end 175 | 176 | def self.generate(node : UnionTypeDefinition, indent : String = "") 177 | out = generate_description(node) 178 | out += "union #{node.name}" 179 | out += generate_directives(node.directives) 180 | out += " = " + node.types.map { |t| t.as(NameOnlyNode).name }.join(" | ") 181 | end 182 | 183 | def self.generate(node : EnumTypeDefinition, indent : String = "") 184 | out = generate_description(node) 185 | out += "enum #{node.name}#{generate_directives(node.directives)} {\n" 186 | node.fvalues.each_with_index do |value, i| 187 | out += generate_description(value, indent: " ", first_in_block: i == 0) 188 | out += generate(value) || "" 189 | end 190 | out += "}" 191 | end 192 | 193 | def self.generate(node : EnumValueDefinition, indent : String = "") 194 | out = " #{node.name}" 195 | out += generate_directives(node.directives) 196 | out += "\n" 197 | end 198 | 199 | def self.generate(node : InputObjectTypeDefinition, indent : String = "") 200 | out = generate_description(node) 201 | out += "input #{node.name}" 202 | out += generate_directives(node.directives) 203 | out += " {\n" 204 | node.fields.each.with_index do |field, i| 205 | out += generate_description(field, indent: " ", first_in_block: i == 0) 206 | out += " #{generate(field)}\n" 207 | end 208 | out += "}" 209 | end 210 | 211 | def self.generate(node : DirectiveDefinition, indent : String = "") 212 | out = generate_description(node) 213 | out += "directive @#{node.name}" 214 | out += "(#{node.arguments.map { |a| generate(a).as(String) }.join(", ")})" unless node.arguments.empty? 215 | out += " on #{node.locations.join(" | ")}" 216 | end 217 | 218 | # def self.generate(node : ASTNode, indent : String = "") 219 | # node.to_query_string() 220 | # end 221 | 222 | def self.generate(node : Float | Int | String | Nil | Bool, indent : String = "") 223 | node.to_json 224 | end 225 | 226 | def self.generate(node : Symbol, indent : String = "") 227 | node.to_s.capitalize 228 | end 229 | 230 | def self.generate(node : Array, indent : String = "") 231 | "[#{node.map { |v| generate(v) }.join(", ")}]" 232 | end 233 | 234 | def self.generate(node : Hash, indent : String = "") 235 | value = node.map { |k, v| "#{k}: #{generate(v)}" }.join(", ") 236 | "{#{value}}" 237 | end 238 | 239 | def self.generate(node, indent : String = "") 240 | raise "fallback - should never happen" 241 | end 242 | 243 | def self.generate_directives(directives, indent : String = "") 244 | if directives.empty? 245 | "" 246 | else 247 | directives.map { |d| " #{generate(d)}" }.join 248 | end 249 | end 250 | 251 | def self.generate_selections(selections, indent : String = "") 252 | if selections.empty? 253 | "" 254 | else 255 | out = " {\n" 256 | selections.each do |selection| 257 | out += generate(selection, indent: indent + " ").to_s + "\n" 258 | end 259 | out += "#{indent}}" 260 | end 261 | end 262 | 263 | def self.generate_description(node, indent = "", first_in_block = true) 264 | return "" unless node_description = node.description 265 | 266 | description = indent != "" && !first_in_block ? "\n" : "" 267 | if node_description.includes?('\n') 268 | description += "#{indent}\"\"\"" 269 | description += node_description.split('\n').map { |s| "#{indent}#{s.strip}" }.join('\n') 270 | description += "#{indent}\"\"\"\n" 271 | else 272 | description += "#{indent}\"#{node_description.gsub("\"", "\\\"")}\"\n" 273 | end 274 | 275 | description 276 | end 277 | 278 | def self.generate_field_definitions(fields, indent : String = "") 279 | out = " {\n" 280 | fields.each.with_index do |field, i| 281 | out += generate_description(field, indent: " ", first_in_block: i == 0) 282 | out += "#{generate(field, indent: " ")}\n" 283 | end 284 | out += "}" 285 | end 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /src/graphql/language/lexer.cr: -------------------------------------------------------------------------------- 1 | require "./token" 2 | require "./lexer_context" 3 | 4 | class GraphQL::Language::Lexer 5 | def lex(source : String | IO) 6 | lex(source, 0) 7 | end 8 | 9 | def lex(source : String | IO, start_position : Int32) : Token 10 | context = Language::LexerContext.new(source, start_position) 11 | context.get_token 12 | end 13 | 14 | def self.lex(source : String | IO) 15 | GraphQL::Language::Lexer.new.lex(source) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/graphql/language/lexer_context.cr: -------------------------------------------------------------------------------- 1 | class GraphQL::Language::LexerContext 2 | def initialize(source : String, index : Int32) 3 | @current_index = index 4 | @source = source 5 | end 6 | 7 | def get_token : Token 8 | return create_eof_token() if @source.nil? 9 | 10 | @current_index = get_position_after_whitespace(@source, @current_index) 11 | 12 | return create_eof_token() if @current_index >= @source.size 13 | 14 | code = @source[@current_index] 15 | 16 | validate_character_code(code) 17 | 18 | token = check_for_punctuation_tokens(code) 19 | 20 | return token unless token.nil? 21 | 22 | return read_comment() if code == '#' 23 | return read_name() if code.letter? || code == '_' 24 | return read_number() if code.number? || code == '-' 25 | return read_string() if code == '"' 26 | 27 | raise ParserError.new("Unexpected character '#{code}' at #{@current_index} near #{@source[@current_index - 15, 30]}") 28 | end 29 | 30 | def only_hex_in_string(test) 31 | !!test.match(/\A\b[0-9a-fA-F]+\b\Z/) 32 | end 33 | 34 | def read_comment : Token 35 | start = @current_index 36 | chunk_start = (@current_index += 1) 37 | 38 | code = get_code 39 | value = "" 40 | 41 | while is_not_at_the_end_of_query() && code.ord != 0x000A && code.ord != 0x000D 42 | code = process_character(pointerof(value), pointerof(chunk_start)) 43 | end 44 | 45 | value += @source[chunk_start, (@current_index - chunk_start)] 46 | 47 | Token.new(Token::Kind::COMMENT, value.strip, start, @current_index + 1) 48 | end 49 | 50 | def read_number : Token 51 | is_float = false 52 | start = @current_index 53 | code = @source[start] 54 | code = self.next_code if code == '-' 55 | next_code_char = code == '0' ? self.next_code : read_digits_from_own_source(code) 56 | raise ParserError.new("Invalid number, unexpected digit after #{code}: #{next_code_char}") if next_code_char.ord >= 48 && next_code_char.ord <= 57 57 | 58 | code = next_code_char 59 | if code == '.' 60 | is_float = true 61 | code = read_digits_from_own_source(self.next_code) 62 | end 63 | 64 | if code == 'E' || code == 'e' 65 | is_float = true 66 | code = self.next_code 67 | if code == '+' || code == '-' 68 | code = self.next_code 69 | end 70 | read_digits_from_own_source(code) 71 | end 72 | 73 | is_float ? create_float_token(start) : create_int_token(start) 74 | end 75 | 76 | def read_string : Token 77 | start = @current_index 78 | value, is_block = process_string_chunks() 79 | 80 | Token.new(is_block ? Token::Kind::STRING : Token::Kind::BLOCK_STRING, value, start, @current_index + 1) 81 | end 82 | 83 | private def is_valid_name_character(code) : Bool 84 | code == '_' || code.alphanumeric? 85 | end 86 | 87 | private def append_characters_from_last_chunk(value, chunk_start) 88 | value + @source[chunk_start, (@current_index - chunk_start - 1)] 89 | end 90 | 91 | private def append_to_value_by_code(value, code) 92 | case code 93 | when '"' 94 | value + '"' 95 | when '/' 96 | value + '/' 97 | when '\\' 98 | value + '\\' 99 | when 'b' 100 | value + '\b' 101 | when 'f' 102 | value + '\f' 103 | when 'n' 104 | value + '\n' 105 | when 'r' 106 | value + '\r' 107 | when 't' 108 | value + '\t' 109 | when 'u' 110 | value + get_unicode_char 111 | else 112 | raise ParserError.new("Invalid character escape sequence: \\#{code}.") 113 | end 114 | end 115 | 116 | private def check_for_invalid_characters(code) 117 | raise ParserError.new("Invalid character within String: #{code}.") if code.ord < 0x0020 && code.ord != 0x0009 118 | end 119 | 120 | private def check_for_punctuation_tokens(code) 121 | case code 122 | when '!' 123 | create_punctuation_token(Token::Kind::BANG, 1) 124 | when '$' 125 | create_punctuation_token(Token::Kind::DOLLAR, 1) 126 | when '(' 127 | create_punctuation_token(Token::Kind::PAREN_L, 1) 128 | when ')' 129 | create_punctuation_token(Token::Kind::PAREN_R, 1) 130 | when '.' 131 | check_for_spread_operator() 132 | when ':' 133 | create_punctuation_token(Token::Kind::COLON, 1) 134 | when '=' 135 | create_punctuation_token(Token::Kind::EQUALS, 1) 136 | when '@' 137 | create_punctuation_token(Token::Kind::AT, 1) 138 | when '[' 139 | create_punctuation_token(Token::Kind::BRACKET_L, 1) 140 | when ']' 141 | create_punctuation_token(Token::Kind::BRACKET_R, 1) 142 | when '{' 143 | create_punctuation_token(Token::Kind::BRACE_L, 1) 144 | when '|' 145 | create_punctuation_token(Token::Kind::PIPE, 1) 146 | when '}' 147 | create_punctuation_token(Token::Kind::BRACE_R, 1) 148 | else 149 | nil 150 | end 151 | end 152 | 153 | private def check_for_spread_operator : Token? 154 | char1 = @source.size > @current_index + 1 ? @source[@current_index + 1] : 0 155 | char2 = @source.size > @current_index + 2 ? @source[@current_index + 2] : 0 156 | 157 | return create_punctuation_token(Token::Kind::SPREAD, 3) if char1 == '.' && char2 == '.' 158 | end 159 | 160 | private def check_string_termination(code) 161 | raise ParserError.new("Unterminated string.") if code != '"' 162 | end 163 | 164 | private def create_eof_token : Token 165 | Token.new(Token::Kind::EOF, nil, @current_index, @current_index) 166 | end 167 | 168 | private def create_float_token(start) : Token 169 | Token.new(Token::Kind::FLOAT, @source[start, (@current_index - start)], start, @current_index) 170 | end 171 | 172 | private def create_int_token(start) : Token 173 | Token.new(Token::Kind::INT, @source[start, (@current_index - start)], start, @current_index) 174 | end 175 | 176 | private def create_name_token(start) : Token 177 | Token.new(Token::Kind::NAME, @source[start, (@current_index - start)], start, @current_index) 178 | end 179 | 180 | private def create_punctuation_token(kind, offset) : Token 181 | Token.new(kind, nil, @current_index, @current_index + offset) 182 | end 183 | 184 | private def get_position_after_whitespace(body : String, start) 185 | position = start 186 | 187 | while position < body.size 188 | code = body[position] 189 | case code 190 | when '\uFEFF', '\t', ' ', '\n', '\r', ',' 191 | position += 1 192 | else 193 | return position 194 | end 195 | end 196 | 197 | position 198 | end 199 | 200 | private def get_unicode_char 201 | if @current_index + 5 > @source.size 202 | truncated_expression = @source[@current_index, @source.size] 203 | raise ParserError.new("Invalid character escape sequence at EOF: \\#{truncated_expression}.") 204 | end 205 | 206 | expression = @source[@current_index, 5] 207 | 208 | if !only_hex_in_string(expression[1, expression.size]) 209 | raise ParserError.new("Invalid character escape sequence: \\#{expression}.") 210 | end 211 | 212 | s = next_code.bytes << 12 | next_code.bytes << 8 | next_code.bytes << 4 | next_code.bytes 213 | String.new(Slice.new(s.to_unsafe, 4))[0] 214 | end 215 | 216 | private def if_unicode_get_string : String 217 | @source.size > @current_index + 5 && 218 | only_hex_in_string(@source[(@current_index + 2), 4]) ? @source[@current_index, 6] : null 219 | end 220 | 221 | private def is_not_at_the_end_of_query 222 | @current_index < @source.size 223 | end 224 | 225 | private def next_code 226 | @current_index += 1 227 | is_not_at_the_end_of_query() ? @source[@current_index] : Char::ZERO 228 | end 229 | 230 | private def process_character(value_ptr, chunk_start_ptr) 231 | code = get_code 232 | @current_index += 1 233 | 234 | if code == '\\' 235 | value_ptr.value = append_to_value_by_code(append_characters_from_last_chunk(value_ptr.value, chunk_start_ptr.value), get_code) 236 | 237 | @current_index += 1 238 | chunk_start_ptr.value = @current_index 239 | end 240 | 241 | get_code 242 | end 243 | 244 | private def process_string_chunks 245 | is_block = @source[@current_index..@current_index + 2] == %(""") 246 | chunk_start = (@current_index += (is_block ? 3 : 1)) 247 | code = get_code 248 | value = "" 249 | 250 | while is_not_at_the_end_of_query() && (is_block || code.ord != 0x000A && code.ord != 0x000D) && (is_block ? @source[@current_index..@current_index + 2] != %(""") : code != '"') 251 | check_for_invalid_characters(code) unless is_block && code.ord == 0x000A 252 | code = process_character(pointerof(value), pointerof(chunk_start)) 253 | end 254 | 255 | value += @source[chunk_start, (@current_index - chunk_start)] 256 | 257 | check_string_termination(code) 258 | 2.times.each { check_string_termination(process_character(pointerof(value), pointerof(chunk_start))) } if is_block 259 | 260 | {value, is_block} 261 | end 262 | 263 | private def read_digits(source, start, first_code) 264 | body = source 265 | position = start 266 | code = first_code 267 | 268 | if !code.number? 269 | raise ParserError.new("Invalid number, expected digit but got: #{resolve_char_name(code)}") 270 | end 271 | 272 | loop do 273 | code = (position += 1) < body.size ? body[position] : Char::ZERO 274 | break unless code.number? 275 | end 276 | 277 | position 278 | end 279 | 280 | private def read_digits_from_own_source(code) 281 | @current_index = read_digits(@source, @current_index, code) 282 | get_code 283 | end 284 | 285 | private def read_name 286 | start = @current_index 287 | code = Char::ZERO 288 | 289 | loop do 290 | @current_index += 1 291 | code = get_code 292 | break unless is_not_at_the_end_of_query && is_valid_name_character(code) 293 | end 294 | 295 | create_name_token(start) 296 | end 297 | 298 | private def resolve_char_name(code, unicode_string = nil) 299 | return "" if code == '\0' 300 | 301 | return "\"#{unicode_string}\"" if unicode_string && !unicode_string.blank? 302 | "\"#{code}\"" 303 | end 304 | 305 | private def validate_character_code(code) 306 | i32_code = code.ord 307 | if (i32_code < 0x0020) && (i32_code != 0x0009) && (i32_code != 0x000A) && (i32_code != 0x000D) 308 | raise ParserError.new("Invalid character \"\\u#{code}\".") 309 | end 310 | end 311 | 312 | private def wait_for_end_of_comment(body, position, code) 313 | while (position += 1) < body.size && (code = body[position]) != 0 && (code.ord > 0x001F || code.ord == 0x0009) && code.ord != 0x000A && code.ord != 0x000D 314 | end 315 | 316 | position 317 | end 318 | 319 | private def get_code 320 | is_not_at_the_end_of_query ? @source[@current_index] : Char::ZERO 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /src/graphql/language/nodes.cr: -------------------------------------------------------------------------------- 1 | require "./ast" 2 | require "./generation" 3 | 4 | module GraphQL 5 | module Language 6 | macro define_array_cast(type) 7 | def self.to_{{type.id.downcase}}(value : Array) : {{type.id}} 8 | _values = [] of {{type.id}} 9 | value.each do |val| 10 | _values << to_{{type.id.downcase}}(val) 11 | end 12 | _values 13 | end 14 | 15 | def self.to_{{type.id.downcase}}(value) {{type.id}} 16 | value.as({{type.id}}) 17 | end 18 | 19 | def self.to_fvalue(v : NullValue) : Nil 20 | nil 21 | end 22 | 23 | def self.to_argumentvalue(v : NullValue) : Nil 24 | nil 25 | end 26 | end 27 | 28 | # This is the AST root for normal queries 29 | # 30 | # @example Deriving a document by parsing a string 31 | # document = GraphQL.parse(query_string) 32 | # 33 | class Document < ASTNode 34 | values({definitions: Array(OperationDefinition | FragmentDefinition | SchemaDefinition | ObjectTypeDefinition | InputObjectTypeDefinition | 35 | ScalarTypeDefinition | DirectiveDefinition | EnumTypeDefinition | InterfaceTypeDefinition | UnionTypeDefinition)}) 36 | 37 | def children 38 | [definitions] 39 | end 40 | 41 | def to_s(io : IO) 42 | GraphQL::Language::Generation.generate(self).to_s(io) 43 | end 44 | 45 | # def slice_definition(name) 46 | # GraphQL::Language::DefinitionSlice.slice(self, name) 47 | # end 48 | end 49 | 50 | class SchemaDefinition < ASTNode 51 | values({query: String, mutation: String?, subscription: String?, directives: Array(Directive)}) 52 | end 53 | 54 | # A query, mutation or subscription. 55 | # May be anonymous or named. 56 | # May be explicitly typed (eg `mutation { ... }`) or implicitly a query (eg `{ ... }`). 57 | class OperationDefinition < ASTNode 58 | values( 59 | { 60 | operation_type: String, 61 | name: String?, 62 | variables: Array(VariableDefinition), 63 | directives: Array(Directive), 64 | selections: Array(Selection), 65 | } 66 | ) 67 | 68 | def children 69 | [variables, directives, selections] 70 | end 71 | end 72 | 73 | class DirectiveDefinition < ASTNode 74 | values({name: String, arguments: Array(InputValueDefinition), locations: Array(String), description: String?}) 75 | 76 | def children 77 | [arguments] 78 | end 79 | end 80 | 81 | class Directive < ASTNode 82 | values({name: String, arguments: Array(Argument)}) 83 | 84 | def children 85 | [arguments] 86 | end 87 | end 88 | 89 | alias FValue = String | Int32 | Float64 | Bool | Nil | AEnum | InputObject | Array(FValue) | Hash(String, FValue) 90 | 91 | define_array_cast(FValue) 92 | 93 | alias Type = TypeName | NonNullType | ListType 94 | alias Selection = Field | FragmentSpread | InlineFragment 95 | 96 | class VariableDefinition < ASTNode 97 | values({name: String, type: Type, default_value: FValue}) 98 | 99 | def children 100 | [type] 101 | end 102 | end 103 | 104 | alias ArgumentValue = FValue | InputObject | VariableIdentifier | Array(ArgumentValue) 105 | 106 | define_array_cast(ArgumentValue) 107 | 108 | class Argument < ASTNode 109 | values({name: String, value: ArgumentValue}) 110 | 111 | def to_value 112 | value 113 | end 114 | end 115 | 116 | class TypeDefinition < ASTNode 117 | values({name: String, description: String?}) 118 | end 119 | 120 | class ScalarTypeDefinition < TypeDefinition 121 | values({directives: Array(Directive)}) 122 | 123 | def children 124 | [directives] 125 | end 126 | end 127 | 128 | class ObjectTypeDefinition < TypeDefinition 129 | values( 130 | {interfaces: Array(String), 131 | fields: Array(FieldDefinition), 132 | directives: Array(Directive)} 133 | ) 134 | 135 | def children 136 | [fields, directives] 137 | end 138 | end 139 | 140 | class InputObjectTypeDefinition < TypeDefinition 141 | values({fields: Array(InputValueDefinition), directives: Array(Directive)}) 142 | 143 | def children 144 | [fields, directives] 145 | end 146 | end 147 | 148 | class InputValueDefinition < ASTNode 149 | values({name: String, type: Type, default_value: FValue, directives: Array(Directive), description: String?}) 150 | 151 | def children 152 | [type, directives] 153 | end 154 | end 155 | 156 | # Base class for nodes whose only value is a name (no child nodes or other scalars) 157 | class NameOnlyNode < ASTNode 158 | values({name: String}) 159 | end 160 | 161 | # Base class for non-null type names and list type names 162 | class WrapperType < ASTNode 163 | values({of_type: Type}) 164 | 165 | def children 166 | [of_type] 167 | end 168 | end 169 | 170 | # A type name, used for variable definitions 171 | class TypeName < NameOnlyNode; end 172 | 173 | # A list type definition, denoted with `[...]` (used for variable type definitions) 174 | class ListType < WrapperType; end 175 | 176 | # A collection of key-value inputs which may be a field argument 177 | 178 | class InputObject < ASTNode 179 | values({arguments: Array(Argument)}) 180 | 181 | def children 182 | [arguments] 183 | end 184 | 185 | # @return [Hash] Recursively turn this input object into a Ruby Hash 186 | def to_h 187 | arguments.reduce({} of String => FValue) do |memo, pair| 188 | v = pair.value 189 | memo[pair.name] = case v 190 | when InputObject 191 | v.to_h 192 | when Array 193 | v.map { |val| val.as(FValue) } 194 | else 195 | v 196 | end.as(FValue) 197 | memo 198 | end 199 | end 200 | 201 | def to_value 202 | to_h 203 | end 204 | end 205 | 206 | # A non-null type definition, denoted with `...!` (used for variable type definitions) 207 | class NonNullType < WrapperType; end 208 | 209 | # An enum value. The string is available as {#name}. 210 | class AEnum < NameOnlyNode 211 | def to_value 212 | name 213 | end 214 | end 215 | 216 | # A null value literal. 217 | class NullValue < NameOnlyNode; end 218 | 219 | class VariableIdentifier < NameOnlyNode; end 220 | 221 | # A single selection in a 222 | # A single selection in a GraphQL query. 223 | class Field < ASTNode 224 | values({ 225 | name: String, 226 | _alias: String?, 227 | arguments: Array(Argument), 228 | directives: Array(Directive), 229 | selections: Array(Selection), 230 | }) 231 | 232 | def children 233 | [arguments, directives, selections] 234 | end 235 | end 236 | 237 | class FragmentDefinition < ASTNode 238 | values({ 239 | name: String?, 240 | type: Type, 241 | directives: Array(Directive), 242 | selections: Array(Selection), 243 | }) 244 | 245 | def children 246 | [type, directives, selections] 247 | end 248 | end 249 | 250 | class FieldDefinition < ASTNode 251 | values({name: String, arguments: Array(InputValueDefinition), type: Type, directives: Array(Directive), description: String?}) 252 | 253 | def children 254 | [type, arguments, directives] 255 | end 256 | end 257 | 258 | class InterfaceTypeDefinition < TypeDefinition 259 | values({fields: Array(FieldDefinition), directives: Array(Directive)}) 260 | 261 | def children 262 | [fields, directives] 263 | end 264 | end 265 | 266 | class UnionTypeDefinition < TypeDefinition 267 | values({types: Array(TypeName), directives: Array(Directive)}) 268 | 269 | def children 270 | [types, directives] 271 | end 272 | end 273 | 274 | class EnumTypeDefinition < TypeDefinition 275 | values({fvalues: Array(EnumValueDefinition), directives: Array(Directive)}) 276 | 277 | def children 278 | [directives] 279 | end 280 | end 281 | 282 | # Application of a named fragment in a selection 283 | class FragmentSpread < ASTNode 284 | values({name: String, directives: Array(Directive)}) 285 | 286 | def children 287 | [directives] 288 | end 289 | end 290 | 291 | # An unnamed fragment, defined directly in the query with `... { }` 292 | class InlineFragment < ASTNode 293 | values({type: Type?, directives: Array(Directive), selections: Array(Selection)}) 294 | 295 | def children 296 | [type, directives, selections] 297 | end 298 | end 299 | 300 | class EnumValueDefinition < ASTNode 301 | values({name: String, directives: Array(Directive), selection: Array(Selection)?, description: String?}) 302 | 303 | def children 304 | [directives] 305 | end 306 | end 307 | end 308 | end 309 | -------------------------------------------------------------------------------- /src/graphql/language/parser.cr: -------------------------------------------------------------------------------- 1 | require "./parser_context" 2 | 3 | class GraphQL::Language::Parser 4 | property max_nesting = 512 5 | 6 | def initialize(lexer : Language::Lexer) 7 | @lexer = lexer 8 | end 9 | 10 | def parse(source : String) : Language::Document 11 | context = Language::ParserContext.new(source, @lexer) 12 | context.parse 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/graphql/language/parser_context.cr: -------------------------------------------------------------------------------- 1 | require "./token" 2 | 3 | class GraphQL::Language::ParserContext 4 | @current_token : Token 5 | @descriptions = [] of String 6 | 7 | def initialize(source : String, lexer : Language::Lexer) 8 | @source = source 9 | @lexer = lexer 10 | 11 | @current_token = @lexer.lex(@source) 12 | end 13 | 14 | def parse 15 | parse_document 16 | end 17 | 18 | def dispose 19 | raise ParserError.new("ParserContext has {descriptions.Count} not applied descriptions.") if descriptions.Count > 0 20 | end 21 | 22 | private def advance 23 | @current_token = @lexer.lex(@source, @current_token.end_position) 24 | end 25 | 26 | private def advance_through_colon_and_parse_type 27 | expect(Token::Kind::COLON) 28 | parse_type 29 | end 30 | 31 | private def any(open : Token::Kind, next_proc : Proc(T), close : Token::Kind) : Array(T) forall T 32 | expect(open) 33 | 34 | parse_comment 35 | 36 | nodes = [] of T 37 | while !skip(close) 38 | nodes.push(next_proc.call) 39 | end 40 | 41 | nodes 42 | end 43 | 44 | private def create_document(start, definitions) 45 | Language::Document.new( 46 | # Language::Location.new(start, @current_token.end_position), 47 | definitions 48 | ) 49 | end 50 | 51 | private def create_field(start : Int32, name : String, f_alias) : Language::Field 52 | Language::Field.new( 53 | name: name, 54 | _alias: f_alias, 55 | arguments: parse_arguments, 56 | directives: parse_directives, 57 | selections: peek(Token::Kind::BRACE_L) ? parse_selection_set : [] of String 58 | ) 59 | end 60 | 61 | private def create_graphql_fragment_spread(start) 62 | Language::FragmentSpread.new( 63 | parse_fragment_name.not_nil!, 64 | parse_directives, 65 | ) 66 | end 67 | 68 | private def create_inline_fragment(start) 69 | Language::InlineFragment.new( 70 | get_type_condition, 71 | parse_directives, 72 | parse_selection_set, 73 | ) 74 | end 75 | 76 | private def create_operation_definition(start, operation, name) 77 | Language::OperationDefinition.new( 78 | operation_type: operation, 79 | name: name, 80 | variables: parse_variable_definitions, 81 | directives: parse_directives, 82 | selections: parse_selection_set, 83 | ) 84 | end 85 | 86 | private def create_operation_definition(start) 87 | Language::OperationDefinition.new( 88 | operation_type: "query", 89 | name: nil, 90 | variables: parse_variable_definitions, 91 | directives: [] of Language::Directive, 92 | selections: parse_selection_set, 93 | ) 94 | end 95 | 96 | private def expect(kind) 97 | if @current_token.kind == kind 98 | advance 99 | else 100 | raise ParserError.new("Expected #{Token.get_token_kind_description(kind)}, found #{@current_token.kind} #{@current_token.value}") 101 | end 102 | end 103 | 104 | private def expect_colon_and_parse_value_literal(is_constant) 105 | expect(Token::Kind::COLON) 106 | parse_value_literal(is_constant) 107 | end 108 | 109 | private def expect_keyword(keyword) 110 | token = @current_token 111 | if token.kind == Token::Kind::NAME && token.value == keyword 112 | advance 113 | return 114 | end 115 | 116 | raise ParserError.new("Expected \"#{keyword}\", found Name \"#{token.value}\"") 117 | end 118 | 119 | private def expect_on_keyword_and_parse_named_type 120 | expect_keyword("on") 121 | parse_named_type 122 | end 123 | 124 | private def get_default_constant_value 125 | default_value : Language::ArgumentValue? 126 | if skip(Token::Kind::EQUALS) 127 | default_value = parse_constant_value 128 | end 129 | 130 | default_value 131 | end 132 | 133 | private def get_name 134 | peek(Token::Kind::NAME) ? parse_name : nil 135 | end 136 | 137 | private def get_name! 138 | parse_name.not_nil! 139 | end 140 | 141 | private def get_type_condition 142 | type_condition = nil 143 | if @current_token.value != nil && @current_token.value == "on" 144 | advance 145 | type_condition = parse_named_type 146 | end 147 | 148 | type_condition 149 | end 150 | 151 | private def many(open, next_proc, close) 152 | expect(open) 153 | 154 | parse_comment 155 | 156 | nodes = [next_proc.call] 157 | while !skip(close) 158 | nodes.push(next_proc.call) 159 | end 160 | 161 | nodes 162 | end 163 | 164 | private def parse_argument 165 | Language::Argument.new( 166 | name: get_name!, 167 | value: expect_colon_and_parse_value_literal(false), 168 | ) 169 | end 170 | 171 | private def parse_argument_defs 172 | if !peek(Token::Kind::PAREN_L) 173 | return [] of Language::InputValueDefinition 174 | end 175 | 176 | many(Token::Kind::PAREN_L, ->{ parse_input_value_def }, Token::Kind::PAREN_R) 177 | end 178 | 179 | private def parse_arguments 180 | peek(Token::Kind::PAREN_L) ? many(Token::Kind::PAREN_L, ->{ parse_argument }, Token::Kind::PAREN_R) : [] of Language::Argument 181 | end 182 | 183 | private def parse_boolean_value(token) 184 | advance 185 | token.value == "true" 186 | end 187 | 188 | private def parse_constant_value : Language::ArgumentValue 189 | parse_value_literal(true) 190 | end 191 | 192 | private def parse_definition 193 | parse_comment 194 | parse_description 195 | 196 | if peek(Token::Kind::BRACE_L) 197 | return parse_operation_definition 198 | end 199 | 200 | if peek(Token::Kind::NAME) 201 | definition = parse_named_definition 202 | if !definition.nil? 203 | return definition 204 | end 205 | end 206 | 207 | raise ParserError.new("Unexpected #{@current_token.kind} '#{@current_token.value}' at #{@current_token.start_position},#{@current_token.end_position}") 208 | end 209 | 210 | private def parse_definitions_if_not_eof 211 | definitions = [] of Language::ASTNode 212 | if @current_token.kind != Token::Kind::EOF 213 | loop do 214 | # yield parse_definition 215 | definitions.push(parse_definition) 216 | break if skip(Token::Kind::EOF) 217 | end 218 | end 219 | definitions 220 | end 221 | 222 | private def parse_comment 223 | if !peek(Token::Kind::COMMENT) 224 | return nil 225 | end 226 | 227 | text = [] of String? 228 | end_position : Int32 229 | 230 | loop do 231 | text.push(@current_token.value) 232 | end_position = @current_token.end_position 233 | advance 234 | break unless @current_token.kind == Token::Kind::COMMENT 235 | end 236 | 237 | comment = text.join("\n") 238 | comment 239 | end 240 | 241 | private def parse_description 242 | is_block = peek(Token::Kind::BLOCK_STRING) 243 | if !peek(Token::Kind::STRING) && !is_block 244 | return nil 245 | end 246 | 247 | text = [] of String? 248 | end_position : Int32 249 | 250 | loop do 251 | text.push(@current_token.value) 252 | end_position = @current_token.end_position 253 | advance 254 | break unless @current_token.kind == (is_block ? Token::Kind::BLOCK_STRING : Token::Kind::STRING) 255 | end 256 | 257 | description = text.join("\n") 258 | @descriptions.push(description) 259 | description 260 | end 261 | 262 | private def parse_directive 263 | expect(Token::Kind::AT) 264 | Language::Directive.new( 265 | name: get_name!, 266 | arguments: parse_arguments, 267 | ) 268 | end 269 | 270 | private def parse_directive_definition 271 | description = @descriptions.pop? 272 | expect_keyword("directive") 273 | expect(Token::Kind::AT) 274 | 275 | name = get_name! 276 | args = parse_argument_defs 277 | 278 | expect_keyword("on") 279 | locations = parse_directive_locations 280 | 281 | Language::DirectiveDefinition.new( 282 | name: name, 283 | arguments: args, 284 | locations: locations, 285 | description: description 286 | ) 287 | end 288 | 289 | private def parse_directive_locations 290 | locations = [] of String? 291 | 292 | loop do 293 | locations.push(parse_name) 294 | break unless skip(Token::Kind::PIPE) 295 | end 296 | 297 | locations 298 | end 299 | 300 | private def parse_directives 301 | directives = [] of Language::Directive 302 | while peek(Token::Kind::AT) 303 | directives.push(parse_directive) 304 | end 305 | 306 | directives 307 | end 308 | 309 | private def parse_document 310 | start = @current_token.start_position 311 | definitions = parse_definitions_if_not_eof 312 | 313 | create_document(start, definitions) 314 | end 315 | 316 | private def parse_enum_type_definition 317 | description = @descriptions.pop? 318 | expect_keyword("enum") 319 | 320 | Language::EnumTypeDefinition.new( 321 | name: get_name!, 322 | directives: parse_directives, 323 | fvalues: many(Token::Kind::BRACE_L, ->{ parse_enum_value_definition }, Token::Kind::BRACE_R), 324 | description: description, 325 | ) 326 | end 327 | 328 | private def parse_enum_value(token) 329 | advance 330 | Language::AEnum.new(name: token.value.not_nil!) 331 | end 332 | 333 | private def parse_enum_value_definition 334 | parse_description 335 | 336 | description = @descriptions.pop? 337 | 338 | Language::EnumValueDefinition.new( 339 | name: get_name!, 340 | directives: parse_directives, 341 | selection: nil, 342 | description: description, 343 | ) 344 | end 345 | 346 | private def parse_field_definition 347 | parse_description 348 | 349 | description = @descriptions.pop? 350 | name = get_name! 351 | args = parse_argument_defs 352 | expect(Token::Kind::COLON) 353 | 354 | Language::FieldDefinition.new( 355 | name: name, 356 | arguments: args, 357 | type: parse_type, 358 | directives: parse_directives, 359 | description: description, 360 | ) 361 | end 362 | 363 | private def parse_field_selection 364 | start = @current_token.start_position 365 | name_or_alias = parse_name # FIXME is this never null? 366 | name = nil 367 | f_alias = nil 368 | 369 | if skip(Token::Kind::COLON) 370 | name = get_name! 371 | f_alias = name_or_alias 372 | else 373 | f_alias = nil 374 | name = name_or_alias 375 | end 376 | 377 | create_field(start, name.not_nil!, f_alias) 378 | end 379 | 380 | private def parse_float(is_constant) : Float64? 381 | token = @current_token 382 | advance 383 | token.value.not_nil!.to_f64? if !token.value.nil? 384 | end 385 | 386 | private def parse_fragment 387 | start = @current_token.start_position 388 | expect(Token::Kind::SPREAD) 389 | 390 | if peek(Token::Kind::NAME) && @current_token.value != "on" 391 | return create_graphql_fragment_spread(start) 392 | end 393 | 394 | create_inline_fragment(start) 395 | end 396 | 397 | private def parse_fragment_definition 398 | expect_keyword("fragment") 399 | 400 | Language::FragmentDefinition.new( 401 | name: parse_fragment_name, 402 | type: expect_on_keyword_and_parse_named_type, 403 | directives: parse_directives, 404 | selections: parse_selection_set, 405 | ) 406 | end 407 | 408 | private def parse_fragment_name 409 | # raise ParserError.new("Unexpected #{@current_token.kind}") if @current_token.value == "on" 410 | 411 | if @current_token.value == "on" 412 | return nil 413 | end 414 | 415 | parse_name 416 | end 417 | 418 | private def parse_implements_interfaces 419 | types = [] of String? 420 | if @current_token.value == "implements" 421 | advance 422 | 423 | loop do 424 | types.push(parse_name) 425 | break unless peek(Token::Kind::NAME) 426 | end 427 | end 428 | 429 | types 430 | end 431 | 432 | private def parse_input_object_type_definition 433 | description = @descriptions.pop? 434 | expect_keyword("input") 435 | 436 | Language::InputObjectTypeDefinition.new( 437 | name: get_name!, 438 | directives: parse_directives(), 439 | fields: any(Token::Kind::BRACE_L, ->{ parse_input_value_def }, Token::Kind::BRACE_R), 440 | description: description, 441 | ) 442 | end 443 | 444 | private def parse_input_value_def 445 | parse_description 446 | description = @descriptions.pop? 447 | name = get_name! 448 | expect(Token::Kind::COLON) 449 | Language::InputValueDefinition.new( 450 | name: name, 451 | type: parse_type, 452 | default_value: Language.to_fvalue(get_default_constant_value), 453 | directives: parse_directives, 454 | description: description, 455 | ) 456 | end 457 | 458 | private def parse_int(is_constant) : Int32? 459 | token = @current_token 460 | advance 461 | token.value.not_nil!.to_i32? if !token.value.nil? 462 | end 463 | 464 | private def parse_interface_type_definition 465 | description = @descriptions.pop? 466 | expect_keyword("interface") 467 | 468 | Language::InterfaceTypeDefinition.new( 469 | name: get_name!, 470 | directives: parse_directives, 471 | fields: any(Token::Kind::BRACE_L, ->{ parse_field_definition }, Token::Kind::BRACE_R), 472 | description: description, 473 | ) 474 | end 475 | 476 | private def parse_list(is_constant) : Language::ArgumentValue 477 | constant = Proc(Language::ArgumentValue).new { parse_constant_value } 478 | value = Proc(Language::ArgumentValue).new { parse_value_value } 479 | 480 | any(Token::Kind::BRACKET_L, is_constant ? constant : value, Token::Kind::BRACKET_R) 481 | end 482 | 483 | private def parse_name : String? 484 | value = @current_token.value 485 | 486 | expect(Token::Kind::NAME) 487 | value 488 | end 489 | 490 | private def parse_named_definition 491 | case @current_token.value 492 | when "query", "mutation", "subscription" 493 | parse_operation_definition 494 | when "fragment" 495 | parse_fragment_definition 496 | when "schema" 497 | parse_schema_definition 498 | when "scalar" 499 | parse_scalar_type_definition 500 | when "type" 501 | parse_object_type_definition 502 | when "interface" 503 | parse_interface_type_definition 504 | when "union" 505 | parse_union_type_definition 506 | when "enum" 507 | parse_enum_type_definition 508 | when "input" 509 | parse_input_object_type_definition 510 | # when "extend" 511 | # parse_type_extension_definition 512 | when "directive" 513 | parse_directive_definition 514 | else 515 | nil 516 | end 517 | end 518 | 519 | private def parse_named_type : Language::TypeName 520 | Language::TypeName.new(name: get_name!) 521 | end 522 | 523 | private def parse_name_value(is_constant) 524 | token = @current_token 525 | if token.value == "true" || token.value == "false" 526 | return parse_boolean_value(token) 527 | elsif !token.value.nil? 528 | if token.value == "null" 529 | return parse_null_value(token) 530 | else 531 | return parse_enum_value(token) 532 | end 533 | end 534 | 535 | raise ParserError.new("Unexpected #{@current_token}") 536 | end 537 | 538 | private def parse_object(is_constant) 539 | Language::InputObject.new(arguments: parse_object_fields(is_constant)) 540 | end 541 | 542 | private def parse_null_value(token) 543 | advance 544 | nil 545 | end 546 | 547 | private def parse_object_field(is_constant) 548 | Language::Argument.new( 549 | name: get_name!, 550 | value: expect_colon_and_parse_value_literal(is_constant) 551 | ) 552 | end 553 | 554 | private def parse_object_fields(is_constant) 555 | fields = [] of Language::Argument 556 | 557 | expect(Token::Kind::BRACE_L) 558 | while !skip(Token::Kind::BRACE_R) 559 | fields.push(parse_object_field(is_constant)) 560 | end 561 | 562 | fields 563 | end 564 | 565 | private def parse_object_type_definition 566 | description = @descriptions.pop? 567 | 568 | expect_keyword("type") 569 | 570 | Language::ObjectTypeDefinition.new( 571 | name: get_name!, 572 | description: description, 573 | interfaces: parse_implements_interfaces, 574 | directives: parse_directives, 575 | fields: any(Token::Kind::BRACE_L, ->{ parse_field_definition }, Token::Kind::BRACE_R), 576 | ) 577 | end 578 | 579 | private def parse_operation_definition 580 | start = @current_token.start_position 581 | 582 | if peek(Token::Kind::BRACE_L) 583 | return create_operation_definition(start) 584 | end 585 | 586 | create_operation_definition(start, parse_operation_type, get_name) 587 | end 588 | 589 | private def parse_operation_type 590 | token = @current_token 591 | expect(Token::Kind::NAME) 592 | token.value || "query" 593 | end 594 | 595 | private def parse_operation_type_definition 596 | operation = parse_operation_type 597 | expect(Token::Kind::COLON) 598 | type = parse_named_type 599 | 600 | Tuple.new(operation, type) 601 | end 602 | 603 | private def parse_scalar_type_definition 604 | description = @descriptions.pop? 605 | expect_keyword("scalar") 606 | name = get_name! 607 | directives = parse_directives 608 | 609 | Language::ScalarTypeDefinition.new( 610 | name: name, 611 | directives: directives, 612 | description: description, 613 | ) 614 | end 615 | 616 | private def parse_schema_definition 617 | expect_keyword("schema") 618 | directives = parse_directives 619 | definitions = many(Token::Kind::BRACE_L, ->{ parse_operation_type_definition }, Token::Kind::BRACE_R) 620 | 621 | definitions = definitions.as(Array).reduce(Hash(String, String).new) do |memo, pair| 622 | pair.as(Tuple(String, GraphQL::Language::TypeName)).tap { |p| memo[p[0]] = p[1].name } 623 | memo 624 | end 625 | 626 | Language::SchemaDefinition.new( 627 | query: definitions["query"], 628 | mutation: definitions["mutation"]?, 629 | subscription: definitions["subscription"]?, 630 | directives: directives, 631 | ) 632 | end 633 | 634 | private def parse_selection 635 | peek(Token::Kind::SPREAD) ? parse_fragment : parse_field_selection 636 | end 637 | 638 | private def parse_selection_set 639 | many(Token::Kind::BRACE_L, ->{ parse_selection }, Token::Kind::BRACE_R) 640 | end 641 | 642 | private def parse_string(is_constant) 643 | token = @current_token 644 | advance 645 | token.value 646 | end 647 | 648 | private def parse_type 649 | type = nil 650 | if skip(Token::Kind::BRACKET_L) 651 | type = parse_type 652 | expect(Token::Kind::BRACKET_R) 653 | type = Language::ListType.new(of_type: type) 654 | else 655 | type = parse_named_type 656 | end 657 | 658 | if skip(Token::Kind::BANG) 659 | return Language::NonNullType.new(of_type: type) 660 | end 661 | 662 | type 663 | end 664 | 665 | # private def parse_type_extension_definition 666 | # comment = get_comment 667 | # start = @current_token.start_position 668 | # expect_keyword("extend") 669 | # definition = parse_object_type_definition 670 | 671 | # Language::TypeExtensionDefinition.new( 672 | # definition: definition, 673 | # ) 674 | # end 675 | 676 | private def parse_union_members 677 | members = [] of Language::TypeName 678 | 679 | loop do 680 | members.push(Language::TypeName.new(name: parse_named_type.name)) 681 | break unless skip(Token::Kind::PIPE) 682 | end 683 | 684 | members 685 | end 686 | 687 | private def parse_union_type_definition 688 | description = @descriptions.pop? 689 | expect_keyword("union") 690 | name = get_name! 691 | directives = parse_directives 692 | expect(Token::Kind::EQUALS) 693 | types = parse_union_members 694 | 695 | Language::UnionTypeDefinition.new( 696 | name: name, 697 | directives: directives, 698 | types: types, 699 | description: description, 700 | ) 701 | end 702 | 703 | private def parse_value_literal(is_constant) : Language::ArgumentValue 704 | token = @current_token 705 | 706 | case token.kind 707 | when Token::Kind::BRACKET_L 708 | return parse_list(is_constant) 709 | when Token::Kind::BRACE_L 710 | return parse_object(is_constant) 711 | when Token::Kind::INT 712 | return parse_int(is_constant) 713 | when Token::Kind::FLOAT 714 | return parse_float(is_constant) 715 | when Token::Kind::STRING 716 | return parse_string(is_constant) 717 | when Token::Kind::BLOCK_STRING 718 | return parse_string(is_constant) 719 | when Token::Kind::NAME 720 | return parse_name_value(is_constant) 721 | when Token::Kind::DOLLAR 722 | return parse_variable if !is_constant 723 | end 724 | 725 | raise ParserError.new("Unexpected #{@current_token.kind} at #{@current_token.start_position} near #{@source[@current_token.start_position - 15, 30]}") 726 | end 727 | 728 | private def parse_value_value : Language::ArgumentValue 729 | parse_value_literal(false) 730 | end 731 | 732 | private def parse_variable 733 | expect(Token::Kind::DOLLAR) 734 | 735 | Language::VariableIdentifier.new(name: get_name!) 736 | end 737 | 738 | private def parse_variable_definition : Language::VariableDefinition 739 | Language::VariableDefinition.new( 740 | name: parse_variable.name.not_nil!, 741 | type: advance_through_colon_and_parse_type.not_nil!, 742 | default_value: skip_equals_and_parse_value_literal.as(FValue) 743 | ) 744 | end 745 | 746 | private def parse_variable_definitions : Array(Language::VariableDefinition) 747 | if peek(Token::Kind::PAREN_L) 748 | many(Token::Kind::PAREN_L, ->{ parse_variable_definition }, Token::Kind::PAREN_R) 749 | else 750 | [] of Language::VariableDefinition 751 | end 752 | end 753 | 754 | private def peek(kind) 755 | @current_token.kind == kind 756 | end 757 | 758 | private def skip(kind) 759 | parse_comment 760 | 761 | is_current_token_matching = @current_token.kind == kind 762 | advance if is_current_token_matching 763 | is_current_token_matching 764 | end 765 | 766 | private def skip_equals_and_parse_value_literal 767 | skip(Token::Kind::EQUALS) ? parse_value_literal(true) : nil 768 | end 769 | end 770 | -------------------------------------------------------------------------------- /src/graphql/language/token.cr: -------------------------------------------------------------------------------- 1 | class GraphQL::Language::Token 2 | enum Kind 3 | BANG 4 | DOLLAR 5 | PAREN_L 6 | PAREN_R 7 | SPREAD 8 | COLON 9 | EQUALS 10 | AT 11 | BRACKET_L 12 | BRACKET_R 13 | BRACE_L 14 | PIPE 15 | BRACE_R 16 | NAME 17 | INT 18 | FLOAT 19 | STRING 20 | BLOCK_STRING 21 | COMMENT 22 | AMP 23 | EOF 24 | end 25 | 26 | property kind : Kind 27 | property start_position : Int32 28 | property end_position : Int32 29 | property value : String? 30 | 31 | def initialize(kind, value, start_position, end_position) 32 | @kind = kind 33 | @start_position = start_position 34 | @end_position = end_position 35 | @value = value 36 | end 37 | 38 | def self.get_token_kind_description(kind : Kind) : String 39 | case kind 40 | when Kind::EOF 41 | "EOF" 42 | when Kind::BANG 43 | "!" 44 | when Kind::DOLLAR 45 | "$" 46 | when Kind::PAREN_L 47 | "(" 48 | when Kind::PAREN_R 49 | ")" 50 | when Kind::SPREAD 51 | "..." 52 | when Kind::COLON 53 | ":" 54 | when Kind::EQUALS 55 | "=" 56 | when Kind::AT 57 | "@" 58 | when Kind::BRACKET_L 59 | "[" 60 | when Kind::BRACKET_R 61 | "]" 62 | when Kind::BRACE_L 63 | "{" 64 | when Kind::PIPE 65 | "|" 66 | when Kind::BRACE_R 67 | "}" 68 | when Kind::NAME 69 | "Name" 70 | when Kind::INT 71 | "Int" 72 | when Kind::FLOAT 73 | "Float" 74 | when Kind::STRING 75 | "String" 76 | when Kind::COMMENT 77 | "#" 78 | else 79 | "" 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/graphql/mutation_type.cr: -------------------------------------------------------------------------------- 1 | module GraphQL::MutationType 2 | macro included 3 | macro finished 4 | include ::GraphQL::Document 5 | end 6 | end 7 | end 8 | 9 | module GraphQL 10 | abstract class BaseMutation 11 | macro inherited 12 | include GraphQL::ObjectType 13 | include GraphQL::MutationType 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/graphql/object_type.cr: -------------------------------------------------------------------------------- 1 | require "./error" 2 | require "./language" 3 | require "./scalar_type" 4 | require "./internal/convert_value" 5 | 6 | module GraphQL::ObjectType 7 | # :nodoc: 8 | record JSONFragment, json : String, errors : Array(::GraphQL::Error) 9 | 10 | macro included 11 | macro finished 12 | {% verbatim do %} 13 | {% verbatim do %} 14 | 15 | # :nodoc: 16 | def _graphql_type : String 17 | {{ @type.annotation(::GraphQL::Object)["name"] || @type.name.split("::").last }} 18 | end 19 | 20 | # :nodoc: 21 | def _graphql_resolve(context, field : ::GraphQL::Language::Field, json : JSON::Builder) : Array(::GraphQL::Error) 22 | {% begin %} 23 | errors = [] of ::GraphQL::Error 24 | path = field._alias || field.name 25 | 26 | case field.name 27 | {% for var in @type.instance_vars.select(&.annotation(::GraphQL::Field)) %} 28 | when {{ var.annotation(::GraphQL::Field)["name"] || var.name.id.stringify.camelcase(lower: true) }} 29 | errors.concat _graphql_serialize(context, field, self.{{var.name.id}}, json) 30 | {% end %} 31 | {% methods = @type.methods.select(&.annotation(::GraphQL::Field)) %} 32 | {% for ancestor in @type.ancestors %} 33 | {% for method in ancestor.methods.select(&.annotation(::GraphQL::Field)) %} 34 | {% methods << method %} 35 | {% end %} 36 | {% end %} 37 | {% for method in methods %} 38 | when {{ method.annotation(::GraphQL::Field)["name"] || method.name.id.stringify.camelcase(lower: true) }} 39 | value = self.{{method.name.id}}( 40 | {% for arg in method.args %} 41 | {% raise "GraphQL: #{@type.name}##{method.name} args must have type restriction" if arg.restriction.is_a? Nop %} 42 | {% type = arg.restriction.resolve.union_types.find { |t| t != Nil }.resolve %} 43 | {{ arg.name }}: begin 44 | if context.is_a? {{arg.restriction.id}} 45 | context 46 | elsif fa = field.arguments.find {|a| a.name == {{ arg.name.id.stringify.camelcase(lower: true) }}} 47 | GraphQL::Internal.convert_value {{ type }}, fa.value, {{ arg.name.id.camelcase(lower: true) }} 48 | else 49 | {% if !arg.default_value.is_a?(Nop) %} 50 | {{ arg.default_value }}.as({{arg.restriction.id}}) 51 | {% elsif arg.restriction.resolve.nilable? %} 52 | nil 53 | {% else %} 54 | raise ::GraphQL::TypeError.new("missing required argument {{ arg.name.id.camelcase(lower: true) }}") 55 | {% end %} 56 | end 57 | end, 58 | {% end %} 59 | ) 60 | errors.concat _graphql_serialize(context, field, value, json) 61 | {% end %} 62 | when "__typename" 63 | json.string _graphql_type 64 | {% if @type < ::GraphQL::QueryType %} 65 | when "__schema" 66 | json.object do 67 | introspection = ::GraphQL::Introspection::Schema.new(context.document.not_nil!, _graphql_type, context.mutation_type) 68 | introspection._graphql_resolve(context, field.selections, json).each do |error| 69 | errors << error.with_path(path) 70 | end 71 | end 72 | {% end %} 73 | else 74 | raise ::GraphQL::TypeError.new("Field is not defined: #{field.name}") 75 | end 76 | errors 77 | 78 | {% end %} 79 | end 80 | 81 | {% end %} 82 | {% end %} 83 | end 84 | end 85 | 86 | # :nodoc: 87 | private def _graphql_serialize(context, field : ::GraphQL::Language::Field, value, json : ::JSON::Builder) : Array(::GraphQL::Error) 88 | errors = [] of ::GraphQL::Error 89 | path = field._alias || field.name 90 | 91 | case value 92 | when ::GraphQL::ObjectType 93 | json.object do 94 | value._graphql_resolve(context, field.selections, json).each do |error| 95 | errors << error.with_path(path) 96 | end 97 | end 98 | when Array 99 | json.array do 100 | json_fragments = value.map_with_index do |v, i| 101 | channel = Channel(JSONFragment | ::Exception).new 102 | 103 | spawn do 104 | fragment = _graphql_build_json_fragment(context, [path, i]) do |json| 105 | _graphql_serialize(context, field, v, json).map do |error| 106 | error.with_path(i).with_path(path) 107 | end 108 | end 109 | 110 | channel.send(fragment) 111 | rescue ex 112 | # unhandled exception, bubble up 113 | channel.send(ex) 114 | end 115 | 116 | channel 117 | end 118 | 119 | json_fragments.each do |channel| 120 | fragment = channel.receive 121 | raise fragment if fragment.is_a?(::Exception) 122 | errors.concat fragment.errors 123 | 124 | next if fragment.json.empty? 125 | json.raw fragment.json 126 | end 127 | end 128 | when ::Enum 129 | json.string value 130 | when Bool, String, Int32, Float64, Nil, ::GraphQL::ScalarType 131 | value.to_json(json) 132 | else 133 | raise ::GraphQL::TypeError.new("no serialization found for field #{path} on #{_graphql_type}") 134 | end 135 | 136 | errors 137 | end 138 | 139 | # :nodoc: 140 | private def _graphql_skip?(selection : ::GraphQL::Language::Field | ::GraphQL::Language::FragmentSpread | ::GraphQL::Language::InlineFragment) 141 | if skip = selection.directives.find { |d| d.name == "skip" } 142 | if arg = skip.arguments.find { |a| a.name == "if" } 143 | return true if arg.value.as(Bool) 144 | end 145 | end 146 | 147 | if inc = selection.directives.find { |d| d.name == "include" } 148 | if arg = inc.arguments.find { |a| a.name == "if" } 149 | return true if !arg.value.as(Bool) 150 | end 151 | end 152 | 153 | false 154 | end 155 | 156 | # :nodoc: 157 | protected def _graphql_resolve(context, selections : Array(::GraphQL::Language::Selection), json : JSON::Builder) : Array(::GraphQL::Error) 158 | errors = [] of ::GraphQL::Error 159 | json_fragments = Hash(String, Channel(JSONFragment | ::Exception)).new 160 | 161 | selections.each do |selection| 162 | case selection 163 | when ::GraphQL::Language::Field 164 | next if _graphql_skip?(selection) 165 | path = selection._alias || selection.name 166 | json_fragments[path] = Channel(JSONFragment | ::Exception).new 167 | 168 | spawn do 169 | fragment = _graphql_build_json_fragment(context, path) do |json| 170 | _graphql_resolve(context, selection, json) 171 | end 172 | 173 | json_fragments[path].send fragment 174 | rescue ex 175 | # unhandled exception, bubble up 176 | json_fragments[path].send(ex) 177 | end 178 | when ::GraphQL::Language::FragmentSpread 179 | next if _graphql_skip?(selection) 180 | 181 | begin 182 | errors.concat _graphql_resolve(context, selection, json) 183 | rescue e 184 | if message = context.handle_exception(e) 185 | errors << ::GraphQL::Error.new(message, selection.name) 186 | end 187 | end 188 | when ::GraphQL::Language::InlineFragment 189 | next if _graphql_skip?(selection) 190 | 191 | errors.concat _graphql_resolve(context, selection.selections, json) 192 | else 193 | # this never happens, only required due to Selection being turned into ASTNode 194 | # https://crystal-lang.org/reference/1.3/syntax_and_semantics/virtual_and_abstract_types.html 195 | raise ::GraphQL::TypeError.new("invalid selection type") 196 | end 197 | end 198 | 199 | json_fragments.each do |path, channel| 200 | fragment = channel.receive 201 | raise fragment if fragment.is_a?(::Exception) 202 | 203 | errors.concat fragment.errors 204 | next if fragment.json.empty? 205 | 206 | json.field(path) { json.raw fragment.json } 207 | end 208 | 209 | errors 210 | end 211 | 212 | # :nodoc: 213 | private def _graphql_resolve(context, fragment : ::GraphQL::Language::FragmentSpread, json : JSON::Builder) : Array(::GraphQL::Error) 214 | errors = [] of ::GraphQL::Error 215 | f = context.fragments.find { |f| f.name == fragment.name } 216 | if f.nil? 217 | errors << ::GraphQL::Error.new("no fragment #{fragment.name}", fragment.name) 218 | else 219 | errors.concat _graphql_resolve(context, f.selections, json) 220 | end 221 | errors 222 | end 223 | 224 | # :nodoc: 225 | private def _graphql_build_json_fragment(context, path : String | Array(Int32 | String), & : JSON::Builder -> Array(::GraphQL::Error)) : JSONFragment 226 | errors = [] of ::GraphQL::Error 227 | 228 | json = String.build do |io| 229 | builder = JSON::Builder.new(io) 230 | builder.document do 231 | errors.concat yield builder 232 | end 233 | rescue e 234 | if message = context.handle_exception(e) 235 | errors << ::GraphQL::Error.new(message, path) 236 | end 237 | end 238 | 239 | JSONFragment.new(json, errors) 240 | end 241 | end 242 | 243 | module GraphQL 244 | abstract class BaseObject 245 | macro inherited 246 | include GraphQL::ObjectType 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /src/graphql/query_type.cr: -------------------------------------------------------------------------------- 1 | module GraphQL::QueryType 2 | macro included 3 | macro finished 4 | include ::GraphQL::Document 5 | end 6 | end 7 | end 8 | 9 | module GraphQL 10 | abstract class BaseQuery 11 | macro inherited 12 | include GraphQL::ObjectType 13 | include GraphQL::QueryType 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/graphql/scalar_type.cr: -------------------------------------------------------------------------------- 1 | module GraphQL::ScalarType 2 | abstract def to_json(builder : JSON::Builder) 3 | end 4 | 5 | module GraphQL 6 | abstract class BaseScalar 7 | macro inherited 8 | include GraphQL::ScalarType 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/graphql/scalars.cr: -------------------------------------------------------------------------------- 1 | require "big" 2 | require "./annotations" 3 | require "./scalar_type" 4 | 5 | module GraphQL::Scalars 6 | # Descriptions were taken from Graphql.js 7 | # https://github.com/graphql/graphql-js/blob/main/src/type/scalars.ts 8 | 9 | @[Scalar(description: "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.")] 10 | record String, value : ::String do 11 | include GraphQL::ScalarType 12 | 13 | def self.from_json(string_or_io) 14 | self.new(::String.from_json(string_or_io)) 15 | end 16 | 17 | def to_json(builder) 18 | builder.scalar(@value) 19 | end 20 | end 21 | 22 | @[Scalar(description: "The `Boolean` scalar type represents `true` or `false`.")] 23 | record Boolean, value : ::Bool do 24 | include GraphQL::ScalarType 25 | 26 | def self.from_json(string_or_io) 27 | self.new(::Bool.from_json(string_or_io)) 28 | end 29 | 30 | def to_json(builder) 31 | builder.scalar(@value) 32 | end 33 | end 34 | 35 | @[Scalar(description: "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.")] 36 | record Int, value : ::Int32 do 37 | include GraphQL::ScalarType 38 | 39 | def self.from_json(string_or_io) 40 | self.new(::Int32.from_json(string_or_io)) 41 | end 42 | 43 | def to_json(builder) 44 | builder.scalar(@value) 45 | end 46 | end 47 | 48 | @[Scalar(description: "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).")] 49 | record Float, value : ::Float64 do 50 | include GraphQL::ScalarType 51 | 52 | def self.from_json(string_or_io) 53 | self.new(::Float64.from_json(string_or_io)) 54 | end 55 | 56 | def to_json(builder) 57 | builder.scalar(@value) 58 | end 59 | end 60 | 61 | @[Scalar(description: "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.")] 62 | record ID, value : ::String do 63 | include GraphQL::ScalarType 64 | 65 | def self.from_json(string_or_io) 66 | self.new(::String.from_json(string_or_io)) 67 | end 68 | 69 | def to_json(builder) 70 | builder.scalar(@value) 71 | end 72 | end 73 | 74 | @[Scalar] 75 | record BigInt, value : ::BigInt do 76 | include GraphQL::ScalarType 77 | 78 | def initialize(@value : ::BigInt) 79 | end 80 | 81 | def self.from_json(string_or_io) 82 | self.new(::BigInt.new(::String.from_json(string_or_io))) 83 | end 84 | 85 | def to_json(builder) 86 | builder.scalar(@value.to_s) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /src/graphql/schema.cr: -------------------------------------------------------------------------------- 1 | require "./language" 2 | require "./query_type" 3 | require "./mutation_type" 4 | 5 | module GraphQL 6 | class Schema 7 | getter document : Language::Document 8 | @query : QueryType 9 | @mutation : MutationType? 10 | 11 | # convert JSON value to FValue 12 | private def to_fvalue(any : JSON::Any) : Language::FValue 13 | case raw = any.raw 14 | when Int64 15 | raw.to_i32.as(Language::FValue) 16 | when Hash 17 | args = raw.map do |key, value| 18 | Language::Argument.new(key, to_fvalue(value)) 19 | end 20 | Language::InputObject.new(args) 21 | when Array 22 | raw.map do |value| 23 | to_fvalue(value) 24 | end 25 | else 26 | raw.as(Language::FValue) 27 | end 28 | end 29 | 30 | private def subtitute_variables(node, variables, errors) 31 | case node 32 | when Language::Argument 33 | case value = node.value 34 | when Language::VariableIdentifier 35 | begin 36 | node.value = to_fvalue(variables.not_nil![value.name]) 37 | rescue 38 | errors << Error.new("missing variable #{value.name}", [] of String | Int32) 39 | end 40 | when Array 41 | value.each_with_index do |val, i| 42 | case val 43 | when Language::VariableIdentifier 44 | begin 45 | value[i] = to_fvalue(variables.not_nil![val.name]) 46 | rescue 47 | errors << Error.new("missing variable #{val.name}", [] of String | Int32) 48 | end 49 | else 50 | subtitute_variables(val, variables, errors) 51 | end 52 | end 53 | when Language::InputObject 54 | value.arguments.each do |arg| 55 | subtitute_variables(arg, variables, errors) 56 | end 57 | else 58 | nil 59 | end 60 | when Language::InputObject 61 | node.arguments.each do |arg| 62 | subtitute_variables(arg, variables, errors) 63 | end 64 | else 65 | nil 66 | end 67 | end 68 | 69 | def initialize(@query : QueryType, @mutation : MutationType? = nil) 70 | @document = @query._graphql_document 71 | if mutation = @mutation 72 | mutation._graphql_document.definitions.each do |definition| 73 | next unless definition.is_a?(Language::TypeDefinition) 74 | unless @document.definitions.find { |d| d.is_a?(Language::TypeDefinition) && d.name == definition.name } 75 | @document.definitions << definition 76 | end 77 | end 78 | end 79 | end 80 | 81 | def execute(query : String, variables : Hash(String, JSON::Any)? = nil, operation_name : String? = nil, context = Context.new) : String 82 | String.build do |io| 83 | execute(io, query, variables, operation_name, context) 84 | end 85 | end 86 | 87 | def execute(io : IO, query : String, variables : Hash(String, JSON::Any)? = nil, operation_name : String? = nil, context = Context.new) : Nil 88 | document = Language.parse(query) 89 | operations = [] of Language::OperationDefinition 90 | errors = [] of GraphQL::Error 91 | 92 | context.query_type = @query._graphql_type 93 | context.mutation_type = @mutation.try &._graphql_type 94 | context.document = @document 95 | 96 | document.visit(->(node : Language::ASTNode) { 97 | case node 98 | when Language::OperationDefinition 99 | operations << node 100 | when Language::FragmentDefinition 101 | context.fragments << node 102 | when Language::Argument 103 | subtitute_variables(node, variables, errors) 104 | else 105 | nil 106 | end 107 | }) 108 | 109 | operation = if !errors.empty? 110 | nil 111 | elsif operation_name.nil? && operations.size == 1 112 | operations.first 113 | else 114 | if operation_name.nil? 115 | errors << Error.new("sent more than one operation but did not set operation name", [] of String | Int32) 116 | nil 117 | elsif op = operations.find { |q| q.name == operation_name } 118 | op 119 | else 120 | errors << Error.new("could not find operation with name #{operation_name}", [] of String | Int32) 121 | nil 122 | end 123 | end 124 | 125 | JSON.build(io) do |json| 126 | json.object do 127 | if !operation.nil? && operation.operation_type == "query" 128 | json.field "data" do 129 | json.object do 130 | errors.concat @query._graphql_resolve(context, operation.selections, json) 131 | end 132 | end 133 | elsif !operation.nil? && operation.operation_type == "mutation" 134 | if mutation = @mutation 135 | json.field "data" do 136 | json.object do 137 | errors.concat mutation._graphql_resolve(context, operation.selections, json) 138 | end 139 | end 140 | else 141 | errors << Error.new("mutation operations are not supported", [] of String | Int32) 142 | end 143 | end 144 | unless errors.empty? 145 | json.field "errors" do 146 | errors.to_json(json) 147 | end 148 | end 149 | end 150 | end 151 | end 152 | end 153 | end 154 | --------------------------------------------------------------------------------