quail-graphql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/generators/quail/channel_generator.rb +17 -0
- data/lib/generators/quail/install_generator.rb +75 -0
- data/lib/generators/quail/resource_generator.rb +79 -0
- data/lib/generators/quail/templates/graphql_channel.rb.tt +4 -0
- data/lib/generators/quail/templates/graphql_channel_custom.rb.tt +23 -0
- data/lib/generators/quail/templates/graphql_controller.rb.tt +40 -0
- data/lib/generators/quail/templates/initializer.rb.tt +5 -0
- data/lib/generators/quail/templates/resource.rb.tt +23 -0
- data/lib/generators/quail/templates/schema.rb.tt +21 -0
- data/lib/quail/channel.rb +52 -0
- data/lib/quail/controller_helpers.rb +27 -0
- data/lib/quail/railtie.rb +41 -0
- data/lib/quail/resource/dsl.rb +121 -0
- data/lib/quail/resource/mutation_builder/context.rb +22 -0
- data/lib/quail/resource/mutation_builder/resolvers.rb +42 -0
- data/lib/quail/resource/mutation_builder.rb +139 -0
- data/lib/quail/resource/query_builder.rb +42 -0
- data/lib/quail/resource/subscription_builder.rb +53 -0
- data/lib/quail/resource/type_builder/association_builder.rb +92 -0
- data/lib/quail/resource/type_builder/field_builder.rb +57 -0
- data/lib/quail/resource/type_builder.rb +68 -0
- data/lib/quail/resource.rb +33 -0
- data/lib/quail/schema_builder/discovery.rb +42 -0
- data/lib/quail/schema_builder/type_definitions.rb +44 -0
- data/lib/quail/schema_builder.rb +127 -0
- data/lib/quail/tasks/quail.rake +25 -0
- data/lib/quail/type_map.rb +31 -0
- data/lib/quail/version.rb +5 -0
- data/lib/quail.rb +99 -0
- data/quail_logo.png +0 -0
- data/quail_logo_new.svg +129 -0
- data/sig/quail.rbs +4 -0
- metadata +123 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f4a7270de975b06407fbe6b81ed40135a4c94e2816cc556dbd388fd5b673db12
|
|
4
|
+
data.tar.gz: 5deb33482ef22b0b18dc230fca7a8f90051f09ed81c09e88438d8ff5c19b9f3d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: afa695056fd8c70acf472243252c5415c5c0a3b89e236499718ced6adfd8ba80a5ae7089fc105df615ba5208c2ef18598a548a98c8aa0d0fbcdc8d78451c5c94
|
|
7
|
+
data.tar.gz: 7ff9e3ed9a03729f13b64d40549fe1fcb9a4304a76a45db46426f41f42cb155598eb91a75f4778a1e5b6469c8adcb616c8adbe6abd99cb50572d3001d479a9b2
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This project provides machine-readable documentation for AI coding agents.
|
|
4
|
+
|
|
5
|
+
## LLMs.txt Endpoints
|
|
6
|
+
|
|
7
|
+
The following files are dynamically generated from the Quail documentation site root:
|
|
8
|
+
|
|
9
|
+
- **llms.txt** Index of all documentation pages with links
|
|
10
|
+
- **llms-full.txt** Full content of all pages in one file
|
|
11
|
+
- **llms-small.txt** Condensed version
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Coding agents can fetch these URLs to get context about the Quail documentation:
|
|
16
|
+
|
|
17
|
+
- `https://quail.taywils.me/llms.txt`
|
|
18
|
+
- `https://quail.taywils.me/llms-full.txt`
|
|
19
|
+
- `https://quail.taywils.me/llms-small.txt`
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Demetrious Wilson
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="./quail_logo_new.svg" alt="Quail Logo" width="320">
|
|
3
|
+
|
|
4
|
+
<strong>A Rails-first GraphQL library with a declarative, <a href="https://github.com/okuramasafumi/alba">Alba</a>-inspired DSL built on top of <a href="https://github.com/rmosolgo/graphql-ruby">graphql-ruby</a>.</strong>
|
|
5
|
+
|
|
6
|
+
[](https://github.com/rubocop/rubocop)
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
> ⚠️ **This gem is under active development and has not been published to RubyGems. Please do not use in production.**
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add Quail to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```Gemfile
|
|
16
|
+
gem "quail", git: "https://github.com/taywils/quail.git", branch: "main"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
rails generate quail:install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Documentation
|
|
26
|
+
|
|
27
|
+
📖 **Full documentation:** [Quail Docs Website](https://quail.taywils.me/)
|
|
28
|
+
|
|
29
|
+
📂 **Documentation source:** [quail-docs Github Repo](https://github.com/taywils/quail-docs)
|
|
30
|
+
|
|
31
|
+
The docs site covers the resource DSL, mutations, queries, subscriptions, custom types, authentication wiring, and more.
|
|
32
|
+
|
|
33
|
+
## Contributing
|
|
34
|
+
|
|
35
|
+
Bug reports and pull requests are welcome on GitHub at <https://github.com/taywils/quail>.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
Released under the [MIT License](./LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Quail
|
|
6
|
+
module Generators
|
|
7
|
+
# Generates a customizable GraphQL ActionCable channel for subscriptions.
|
|
8
|
+
class ChannelGenerator < Rails::Generators::Base
|
|
9
|
+
desc "Generate a customizable GraphQL ActionCable channel"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_channel
|
|
13
|
+
template "graphql_channel_custom.rb.tt", "app/channels/graphql_channel.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Quail
|
|
6
|
+
module Generators
|
|
7
|
+
# Sets up Quail in a Rails app: schema, controller, channel, initializer, and directories.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
desc "Set up Quail: schema, controller, route, initializer, and resource directory"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
class_option :schema_name,
|
|
13
|
+
type: :string,
|
|
14
|
+
default: nil,
|
|
15
|
+
desc: "Name for the schema class (default: AppSchema)"
|
|
16
|
+
|
|
17
|
+
class_option :skip_controller,
|
|
18
|
+
type: :boolean,
|
|
19
|
+
default: nil,
|
|
20
|
+
desc: "Skip generating the GraphQL controller"
|
|
21
|
+
|
|
22
|
+
class_option :skip_channel,
|
|
23
|
+
type: :boolean,
|
|
24
|
+
default: false,
|
|
25
|
+
desc: "Skip generating the ActionCable channel"
|
|
26
|
+
|
|
27
|
+
def create_graphql_directories
|
|
28
|
+
%w[resources mutations queries subscriptions types].each do |dir|
|
|
29
|
+
empty_directory "app/graphql/#{dir}"
|
|
30
|
+
create_file "app/graphql/#{dir}/.keep"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_schema
|
|
35
|
+
template "schema.rb.tt", "app/graphql/#{schema_name.underscore}.rb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_controller
|
|
39
|
+
return if options[:skip_controller]
|
|
40
|
+
|
|
41
|
+
template "graphql_controller.rb.tt", "app/controllers/graphql_controller.rb"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_channel
|
|
45
|
+
return if options[:skip_channel]
|
|
46
|
+
|
|
47
|
+
template "graphql_channel.rb.tt", "app/channels/graphql_channel.rb"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_initializer
|
|
51
|
+
template "initializer.rb.tt", "config/initializers/quail.rb"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_route
|
|
55
|
+
return if options[:skip_controller]
|
|
56
|
+
|
|
57
|
+
route 'post "/graphql", to: "graphql#execute"'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def schema_name
|
|
63
|
+
options[:schema_name] || "AppSchema"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def app_name
|
|
67
|
+
if Rails.application.class.respond_to?(:module_parent_name)
|
|
68
|
+
Rails.application.class.module_parent_name
|
|
69
|
+
else
|
|
70
|
+
Rails.application.class.parent_name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Quail
|
|
6
|
+
module Generators
|
|
7
|
+
# Generates a Quail resource file for a given ActiveRecord model.
|
|
8
|
+
class ResourceGenerator < Rails::Generators::NamedBase
|
|
9
|
+
desc "Generate a Quail resource for a model. Usage: rails g quail:resource Article"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
class_option :attributes,
|
|
13
|
+
type: :array,
|
|
14
|
+
default: [],
|
|
15
|
+
desc: "Attributes to expose (default: all columns)"
|
|
16
|
+
|
|
17
|
+
class_option :skip_mutations,
|
|
18
|
+
type: :array,
|
|
19
|
+
default: [],
|
|
20
|
+
desc: "Mutations to skip: create update delete"
|
|
21
|
+
|
|
22
|
+
class_option :skip_queries,
|
|
23
|
+
type: :array,
|
|
24
|
+
default: [],
|
|
25
|
+
desc: "Queries to skip: find list"
|
|
26
|
+
|
|
27
|
+
class_option :subscribe_on,
|
|
28
|
+
type: :array,
|
|
29
|
+
default: [],
|
|
30
|
+
desc: "Events to subscribe on: create update delete"
|
|
31
|
+
|
|
32
|
+
def create_resource
|
|
33
|
+
template "resource.rb.tt", "app/graphql/resources/#{file_name}_resource.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def model_class
|
|
39
|
+
class_name.constantize
|
|
40
|
+
rescue NameError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def attribute_names
|
|
45
|
+
if options[:attributes].any?
|
|
46
|
+
options[:attributes].map(&:to_sym)
|
|
47
|
+
elsif model_class
|
|
48
|
+
model_class.column_names.map(&:to_sym)
|
|
49
|
+
else
|
|
50
|
+
[:id]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def association_lines
|
|
55
|
+
return [] unless model_class
|
|
56
|
+
|
|
57
|
+
model_class.reflect_on_all_associations.map do |association|
|
|
58
|
+
case association.macro
|
|
59
|
+
when :has_many then " has_many :#{association.name}"
|
|
60
|
+
when :has_one then " has_one :#{association.name}"
|
|
61
|
+
when :belongs_to then " belongs_to :#{association.name}"
|
|
62
|
+
end
|
|
63
|
+
end.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def writable_attribute_names
|
|
67
|
+
attribute_names.reject { |c| %i[id created_at updated_at].include?(c) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def skip_mutations_list
|
|
71
|
+
options[:skip_mutations].map { |m| ":#{m}" }.join(", ")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def subscribe_on_list
|
|
75
|
+
options[:subscribe_on].map { |e| ":#{e}" }.join(", ")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Customizable GraphQL subscription channel.
|
|
2
|
+
# Override context_for_subscription to inject your own auth context.
|
|
3
|
+
class GraphqlChannel < Quail::Channel
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
# Add your auth context here. This is passed to every subscription query.
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# def context_for_subscription
|
|
10
|
+
# {
|
|
11
|
+
# channel: self,
|
|
12
|
+
# current_user: find_verified_user
|
|
13
|
+
# }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def find_verified_user
|
|
17
|
+
# # Your auth logic - token-based, cookie-based, JWT, etc...
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
def context_for_subscription
|
|
21
|
+
{ channel: self }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class GraphqlController < ApplicationController
|
|
2
|
+
# If accessing from outside this domain, nullify the session
|
|
3
|
+
# This allows for outside API access while preventing CSRF attacks,
|
|
4
|
+
# but you'll have to authenticate your user separately
|
|
5
|
+
# protect_from_forgery with: :null_session
|
|
6
|
+
|
|
7
|
+
def execute
|
|
8
|
+
result = <%= schema_name %>.execute(
|
|
9
|
+
params[:query],
|
|
10
|
+
variables: normalize_request_params(params[:variables]),
|
|
11
|
+
context: {
|
|
12
|
+
# Bring Your Own Auth - add your current_user here:
|
|
13
|
+
# current_user: current_user,
|
|
14
|
+
},
|
|
15
|
+
operation_name: params[:operationName]
|
|
16
|
+
)
|
|
17
|
+
render json: result
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
raise e unless Rails.env.development?
|
|
20
|
+
handle_error_in_development(e)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_request_params(request_params)
|
|
26
|
+
case request_params
|
|
27
|
+
when String then request_params.present? ? JSON.parse(request_params) : {}
|
|
28
|
+
when Hash then request_params
|
|
29
|
+
when ActionController::Parameters then request_params.to_unsafe_hash
|
|
30
|
+
when nil then {}
|
|
31
|
+
else raise ArgumentError, "Unexpected parameter: #{request_params}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def handle_error_in_development(e)
|
|
36
|
+
logger.error e.message
|
|
37
|
+
logger.error e.backtrace.join("\n")
|
|
38
|
+
render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class <%= class_name %>Resource
|
|
2
|
+
include Quail::Resource
|
|
3
|
+
<% if attribute_names.any? -%>
|
|
4
|
+
|
|
5
|
+
attributes <%= attribute_names.map { |a| ":#{a}" }.join(", ") %>
|
|
6
|
+
<% end -%>
|
|
7
|
+
<% if association_lines.any? -%>
|
|
8
|
+
|
|
9
|
+
<%= association_lines.join("\n") %>
|
|
10
|
+
<% end -%>
|
|
11
|
+
<% if writable_attribute_names.any? -%>
|
|
12
|
+
|
|
13
|
+
writable_attributes <%= writable_attribute_names.map { |a| ":#{a}" }.join(", ") %>
|
|
14
|
+
<% end -%>
|
|
15
|
+
<% if options[:skip_mutations].any? -%>
|
|
16
|
+
|
|
17
|
+
skip_mutations <%= skip_mutations_list %>
|
|
18
|
+
<% end -%>
|
|
19
|
+
<% if options[:subscribe_on].any? -%>
|
|
20
|
+
|
|
21
|
+
subscribe_on <%= subscribe_on_list %>
|
|
22
|
+
<% end -%>
|
|
23
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class <%= schema_name %> < GraphQL::Schema
|
|
2
|
+
Quail::SchemaBuilder.call(self)
|
|
3
|
+
|
|
4
|
+
# Limit the size of incoming queries:
|
|
5
|
+
max_query_string_tokens(5_000)
|
|
6
|
+
|
|
7
|
+
# Stop validating when it encounters this many errors:
|
|
8
|
+
validate_max_errors(100)
|
|
9
|
+
|
|
10
|
+
# Relay-style Object Identification:
|
|
11
|
+
|
|
12
|
+
# Return a string UUID for `object`
|
|
13
|
+
def self.id_from_object(object, type_definition, query_ctx)
|
|
14
|
+
object.to_gid_param
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Given a string UUID, find the object
|
|
18
|
+
def self.object_from_id(global_id, query_ctx)
|
|
19
|
+
GlobalID.find(global_id)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quail
|
|
4
|
+
# ActionCable channel for handling GraphQL subscriptions over WebSocket.
|
|
5
|
+
class Channel < ActionCable::Channel::Base
|
|
6
|
+
def subscribed
|
|
7
|
+
@subscription_ids = []
|
|
8
|
+
result = execute_query
|
|
9
|
+
track_subscription(result)
|
|
10
|
+
transmit(result: result.to_h, more: result.subscription?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unsubscribed
|
|
14
|
+
@subscription_ids&.each do |subscription_id|
|
|
15
|
+
schema_class.subscriptions.delete_subscription(subscription_id)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def execute_query
|
|
22
|
+
schema_class.execute(
|
|
23
|
+
params[:query],
|
|
24
|
+
context: context_for_subscription,
|
|
25
|
+
variables: ensure_hash(params[:variables]),
|
|
26
|
+
operation_name: params[:operation_name]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def track_subscription(result)
|
|
31
|
+
@subscription_ids << result.context[:subscription_id] if result.context[:subscription_id]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def context_for_subscription
|
|
35
|
+
{ channel: self }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def schema_class
|
|
39
|
+
Rails.application.config.quail.schema_class
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_hash(some_param)
|
|
43
|
+
case some_param
|
|
44
|
+
when String then some_param.present? ? JSON.parse(some_param) : {}
|
|
45
|
+
when Hash then some_param
|
|
46
|
+
when ActionController::Parameters then some_param.to_unsafe_hash
|
|
47
|
+
when nil then {}
|
|
48
|
+
else raise ArgumentError, "Unexpected parameter: #{some_param.class}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quail
|
|
4
|
+
# Provides shared helper methods for GraphQL controllers,
|
|
5
|
+
# including request parameter normalization and development error handling.
|
|
6
|
+
module ControllerHelpers
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def normalize_request_params(request_params)
|
|
12
|
+
case request_params
|
|
13
|
+
when String then request_params.present? ? JSON.parse(request_params) : {}
|
|
14
|
+
when Hash then request_params
|
|
15
|
+
when ActionController::Parameters then request_params.to_unsafe_hash
|
|
16
|
+
when nil then {}
|
|
17
|
+
else raise ArgumentError, "Unexpected parameter: #{request_params}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def handle_error_in_development(error)
|
|
22
|
+
logger.error error.message
|
|
23
|
+
logger.error error.backtrace.join("\n")
|
|
24
|
+
render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} }, status: 500
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quail
|
|
4
|
+
# Rails integration for Quail: configures eager loading and schema resolution.
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
config.quail = ActiveSupport::OrderedOptions.new
|
|
7
|
+
config.quail.schema_class = nil
|
|
8
|
+
|
|
9
|
+
initializer "quail.autoload_paths", before: :set_autoload_paths do |app|
|
|
10
|
+
# These subdirectories define top-level constants (e.g. UserResource, not Resources::UserResource)
|
|
11
|
+
# Note: types/ is excluded because custom types use the Types:: namespace by convention
|
|
12
|
+
%w[resources queries mutations subscriptions].each do |subdir|
|
|
13
|
+
path = Rails.root.join("app/graphql/#{subdir}").to_s
|
|
14
|
+
next unless Dir.exist?(path)
|
|
15
|
+
|
|
16
|
+
app.config.autoload_paths << path
|
|
17
|
+
app.config.eager_load_paths << path
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Tell Zeitwerk to ignore these subdirectories from the parent app/graphql/ root
|
|
22
|
+
# so they are only loaded as top-level autoload roots (no module namespace).
|
|
23
|
+
initializer "quail.zeitwerk_ignore", before: :setup_main_autoloader do
|
|
24
|
+
Rails.autoloaders.main.ignore(
|
|
25
|
+
Rails.root.join("app/graphql/resources"),
|
|
26
|
+
Rails.root.join("app/graphql/queries"),
|
|
27
|
+
Rails.root.join("app/graphql/mutations"),
|
|
28
|
+
Rails.root.join("app/graphql/subscriptions")
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
config.after_initialize do |app|
|
|
33
|
+
schema = app.config.quail.schema_class
|
|
34
|
+
app.config.quail.schema_class = schema.constantize if schema.is_a?(String)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rake_tasks do
|
|
38
|
+
load File.expand_path("tasks/quail.rake", __dir__)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quail
|
|
4
|
+
module Resource
|
|
5
|
+
# DSL for declaring attributes, associations, mutations, and subscriptions on a resource.
|
|
6
|
+
module DSL
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend ClassMethods
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Class-level DSL methods mixed into resource classes.
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def model(klass = nil)
|
|
14
|
+
if klass
|
|
15
|
+
@model_class = klass
|
|
16
|
+
else
|
|
17
|
+
@model ||= name.delete_suffix("Resource").constantize
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
alias model_class model
|
|
21
|
+
|
|
22
|
+
def attribute_definitions
|
|
23
|
+
@attribute_definitions ||= {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def attributes(*names)
|
|
27
|
+
names.each { |name| attribute_definitions[name] = { type: :column } }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attribute(name, type: nil, null: nil, &block)
|
|
31
|
+
attribute_definitions[name] = { type: :computed, graphql_type: type, null: null, block: block }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def association_definitions
|
|
35
|
+
@association_definitions ||= {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def has_many(name, resource: nil, **options)
|
|
39
|
+
association_definitions[name] = { kind: :has_many, resource: resource, **options }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def has_one(name, resource: nil, **options)
|
|
43
|
+
association_definitions[name] = { kind: :has_one, resource: resource, **options }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def belongs_to(name, resource: nil, **options)
|
|
47
|
+
if options[:polymorphic]
|
|
48
|
+
validate_polymorphic_options!(options[:polymorphic])
|
|
49
|
+
options[:polymorphic_types] = options[:polymorphic][:types]
|
|
50
|
+
options[:union_name] = options[:polymorphic][:union_name] if options[:polymorphic][:union_name]
|
|
51
|
+
options[:polymorphic] = true
|
|
52
|
+
end
|
|
53
|
+
association_definitions[name] = { kind: :belongs_to, resource: resource, **options }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def skip_mutations(*actions)
|
|
57
|
+
@skipped_mutations = actions.map(&:to_sym)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def skipped_mutations
|
|
61
|
+
@skipped_mutations || []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def override_mutation(action, klass)
|
|
65
|
+
mutation_overrides[action.to_sym] = klass
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mutation_overrides
|
|
69
|
+
@mutation_overrides ||= {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolves mutation override values, constantizing strings lazily.
|
|
73
|
+
def resolved_mutation_overrides
|
|
74
|
+
mutation_overrides.transform_values do |klass|
|
|
75
|
+
klass.is_a?(String) ? klass.constantize : klass
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def writable_attributes(*names)
|
|
80
|
+
if names.any?
|
|
81
|
+
@writable_attributes = names.map(&:to_sym)
|
|
82
|
+
else
|
|
83
|
+
@writable_attributes
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def subscription_definitions
|
|
88
|
+
@subscription_definitions ||= {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def subscribe_on(*events, scope: nil)
|
|
92
|
+
if scope && !scope.is_a?(Symbol) && !scope.is_a?(Hash)
|
|
93
|
+
raise ArgumentError, "subscribe_on scope: must be a Symbol or Hash { key: proc }, got #{scope.class}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
events.each { |event| subscription_definitions[event.to_sym] = { scope: scope } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def skip_queries(*actions)
|
|
100
|
+
@skipped_queries = actions.map(&:to_sym)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def skipped_queries
|
|
104
|
+
@skipped_queries || []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def validate_polymorphic_options!(poly_opt)
|
|
110
|
+
unless poly_opt.is_a?(Hash) && poly_opt.key?(:types)
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"polymorphic requires a Hash with a :types key, e.g. polymorphic: { types: [FooResource] }"
|
|
113
|
+
end
|
|
114
|
+
return unless poly_opt[:types].empty?
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "polymorphic :types must contain at least one resource class or class name string"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quail
|
|
4
|
+
module Resource
|
|
5
|
+
module MutationBuilder
|
|
6
|
+
# Holds the shared context needed by all mutation builders.
|
|
7
|
+
MutationContext = Struct.new(:resource_class) do
|
|
8
|
+
def model = resource_class.model_class
|
|
9
|
+
def type_class = resource_class.graphql_type
|
|
10
|
+
def underscore_name = model.name.underscore
|
|
11
|
+
|
|
12
|
+
def writable
|
|
13
|
+
resource_class.writable_attributes || MutationBuilder.default_writable(model,
|
|
14
|
+
resource_class)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def subscriptions = resource_class.subscription_definitions
|
|
18
|
+
def base = Quail.base_mutation_class || GraphQL::Schema::RelayClassicMutation
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|