├── .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 | 
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 |
65 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
--------------------------------------------------------------------------------