graphql_backed 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/LICENSE.txt +21 -0
- data/README.md +42 -0
- data/lib/graphql_backed/json_schema.rb +310 -0
- data/lib/graphql_backed/mcp.rb +137 -0
- data/lib/graphql_backed/operation.rb +248 -0
- data/lib/graphql_backed/resource.rb +71 -0
- data/lib/graphql_backed/resource_references.rb +64 -0
- data/lib/graphql_backed/rest/app.rb +227 -0
- data/lib/graphql_backed/rest/open_api.rb +179 -0
- data/lib/graphql_backed/rest.rb +181 -0
- data/lib/graphql_backed/service.rb +253 -0
- data/lib/graphql_backed/version.rb +7 -0
- data/lib/graphql_backed.rb +32 -0
- metadata +69 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphQLBacked
|
|
5
|
+
# A GraphQL query or mutation with a single root field (subscriptions aren't supported).
|
|
6
|
+
# It includes each Resource by spreading its class name:
|
|
7
|
+
#
|
|
8
|
+
# class GetUser < GraphQLBacked::Operation
|
|
9
|
+
# graphql <<~GRAPHQL
|
|
10
|
+
# query GetUser($id: ID!) {
|
|
11
|
+
# user(id: $id) { ...Resources::User }
|
|
12
|
+
# }
|
|
13
|
+
# GRAPHQL
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# When .graphql is called, the string is parsed and flattened: each `...Resources::User` is replaced with
|
|
17
|
+
# a valid GraphQL fragment name and the fragment of each referenced Resource is appended, transitively.
|
|
18
|
+
# .query_string returns the flattened GraphQL.
|
|
19
|
+
# It's validated against a schema when it's added to a Service, which also runs it.
|
|
20
|
+
#
|
|
21
|
+
# An operation can describe itself for the people or agents who'll call it (for example, as an MCP tool, see GraphQLBacked::MCP):
|
|
22
|
+
#
|
|
23
|
+
# class ArchiveUser < GraphQLBacked::Operation
|
|
24
|
+
# description "Archive a user by ID"
|
|
25
|
+
# destructive false # default: true for mutations, false for queries
|
|
26
|
+
# idempotent true # default: false for mutations, true for queries
|
|
27
|
+
# open_world false # default: true
|
|
28
|
+
#
|
|
29
|
+
# graphql <<~GRAPHQL
|
|
30
|
+
# mutation ArchiveUser($id: ID!) {
|
|
31
|
+
# archiveUser(id: $id) { ...Resources::User }
|
|
32
|
+
# }
|
|
33
|
+
# GRAPHQL
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# It can also configure the method and path that it's served at over HTTP (see GraphQLBacked::REST):
|
|
37
|
+
#
|
|
38
|
+
# class GetUser < GraphQLBacked::Operation
|
|
39
|
+
# route :get, "/users/:id" # default: `GET /GetUser` for queries, `POST /GetUser` for mutations
|
|
40
|
+
# # ...
|
|
41
|
+
# end
|
|
42
|
+
class Operation
|
|
43
|
+
class << self
|
|
44
|
+
# Assign the GraphQL document for this operation, then flatten it.
|
|
45
|
+
#
|
|
46
|
+
#: (String graphql_string) -> void
|
|
47
|
+
def graphql(graphql_string)
|
|
48
|
+
document, resources = ResourceReferences.parse(graphql_string, owner: self)
|
|
49
|
+
operations = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
|
|
50
|
+
operation = operations.first
|
|
51
|
+
if operations.size != 1 || operation.nil?
|
|
52
|
+
raise InvalidDocumentError, "#{name}: an Operation must contain exactly one query or mutation"
|
|
53
|
+
end
|
|
54
|
+
if operation.operation_type == "subscription"
|
|
55
|
+
raise InvalidDocumentError, "#{name}: subscriptions aren't supported, an Operation must be a query or mutation"
|
|
56
|
+
end
|
|
57
|
+
root_field = operation.selections.first
|
|
58
|
+
if operation.selections.size != 1 || !root_field.is_a?(GraphQL::Language::Nodes::Field)
|
|
59
|
+
raise InvalidDocumentError, "#{name}: an Operation must select exactly one root field"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
resources = resources.dup
|
|
63
|
+
# Appending while iterating visits each resource once, so cycles end here; they're reported when a Service validates the document
|
|
64
|
+
resources.each { |resource| resources.concat(resource.resources - resources) }
|
|
65
|
+
document = document.merge(definitions: document.definitions + resources.map(&:fragment_definition))
|
|
66
|
+
|
|
67
|
+
@document = document #: GraphQL::Language::Nodes::Document?
|
|
68
|
+
@fragments = document.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |f| [f.name, f] } #: Hash[String, GraphQL::Language::Nodes::FragmentDefinition]?
|
|
69
|
+
@definition = operation #: GraphQL::Language::Nodes::OperationDefinition?
|
|
70
|
+
@query_string = document.to_query_string #: String?
|
|
71
|
+
@operation_type = (operation.operation_type || "query").to_sym #: Symbol?
|
|
72
|
+
@root_field_name = root_field.name #: String?
|
|
73
|
+
@resources = resources #: Array[singleton(Resource)]?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get or set a description of what this operation does, for the people or agents who'll call it:
|
|
77
|
+
#
|
|
78
|
+
# class GetUser < GraphQLBacked::Operation
|
|
79
|
+
# description "Look up a user by ID"
|
|
80
|
+
# # ...
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
#: (?String? new_description) -> String?
|
|
84
|
+
def description(new_description = nil)
|
|
85
|
+
if new_description
|
|
86
|
+
@description = new_description #: String?
|
|
87
|
+
end
|
|
88
|
+
@description
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The name which identifies this operation within a Service: the class name without its namespaces.
|
|
92
|
+
#
|
|
93
|
+
# Operations::GetUser.public_name # => "GetUser"
|
|
94
|
+
#
|
|
95
|
+
#: -> String
|
|
96
|
+
def public_name
|
|
97
|
+
class_name = name || raise(Error, "Anonymous Operation classes aren't supported, assign it to a constant")
|
|
98
|
+
class_name.split("::").last #: as !nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The flattened GraphQL string: this operation, followed by the fragments of its resources.
|
|
102
|
+
#
|
|
103
|
+
#: -> String
|
|
104
|
+
def query_string
|
|
105
|
+
@query_string || raise(Error, "#{name} has no GraphQL document, add one with `graphql \"...\"`")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The parsed form of .query_string, including resources' fragments. A Service validates and executes this;
|
|
109
|
+
# to inspect the operation, use .definition.
|
|
110
|
+
#
|
|
111
|
+
#: -> GraphQL::Language::Nodes::Document
|
|
112
|
+
def document
|
|
113
|
+
query_string
|
|
114
|
+
@document #: as !nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The query or mutation in .document, for inspecting its name, variables and selections.
|
|
118
|
+
#
|
|
119
|
+
#: -> GraphQL::Language::Nodes::OperationDefinition
|
|
120
|
+
def definition
|
|
121
|
+
query_string
|
|
122
|
+
@definition #: as !nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Every fragment that .definition may spread, by name: those written in this operation's GraphQL
|
|
126
|
+
# and those of its .resources (named by Resource.fragment_name).
|
|
127
|
+
#
|
|
128
|
+
#: -> Hash[String, GraphQL::Language::Nodes::FragmentDefinition]
|
|
129
|
+
def fragments
|
|
130
|
+
query_string
|
|
131
|
+
@fragments #: as !nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# `:query` or `:mutation`
|
|
135
|
+
#
|
|
136
|
+
#: -> Symbol
|
|
137
|
+
def operation_type
|
|
138
|
+
query_string
|
|
139
|
+
@operation_type #: as !nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
#: -> bool
|
|
143
|
+
def query?
|
|
144
|
+
operation_type == :query
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#: -> bool
|
|
148
|
+
def mutation?
|
|
149
|
+
operation_type == :mutation
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Configure whether this operation may delete or overwrite data. See .destructive?
|
|
153
|
+
#
|
|
154
|
+
#: (bool new_value) -> void
|
|
155
|
+
def destructive(new_value)
|
|
156
|
+
@destructive = new_value #: bool?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Whether this operation may delete or overwrite data, as opposed to only adding to it.
|
|
160
|
+
# Unless configured with .destructive, mutations are assumed to be destructive and queries aren't.
|
|
161
|
+
#
|
|
162
|
+
#: -> bool
|
|
163
|
+
def destructive?
|
|
164
|
+
@destructive.nil? ? mutation? : @destructive
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Configure whether repeating this operation with the same input has no further effect. See .idempotent?
|
|
168
|
+
#
|
|
169
|
+
#: (bool new_value) -> void
|
|
170
|
+
def idempotent(new_value)
|
|
171
|
+
@idempotent = new_value #: bool?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Whether repeating this operation with the same input has no further effect.
|
|
175
|
+
# Unless configured with .idempotent, queries are assumed to be idempotent and mutations aren't.
|
|
176
|
+
#
|
|
177
|
+
#: -> bool
|
|
178
|
+
def idempotent?
|
|
179
|
+
@idempotent.nil? ? query? : @idempotent
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Configure whether this operation interacts with things outside of your system. See .open_world?
|
|
183
|
+
#
|
|
184
|
+
#: (bool new_value) -> void
|
|
185
|
+
def open_world(new_value)
|
|
186
|
+
@open_world = new_value #: bool?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Whether this operation interacts with an open set of things outside of your system (for example, the web),
|
|
190
|
+
# as opposed to only your system's own data. True unless configured with .open_world.
|
|
191
|
+
#
|
|
192
|
+
#: -> bool
|
|
193
|
+
def open_world?
|
|
194
|
+
@open_world.nil? ? true : @open_world
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Configure how this operation is served over HTTP by Service.rest_app (see GraphQLBacked::REST):
|
|
198
|
+
#
|
|
199
|
+
# route :get, "/users/:id"
|
|
200
|
+
#
|
|
201
|
+
# `http_method` is `:get`, `:post`, `:put`, `:patch` or `:delete`. Each `:name` in `path` is passed
|
|
202
|
+
# as the variable `$name`.
|
|
203
|
+
#
|
|
204
|
+
#: (Symbol http_method, String path) -> void
|
|
205
|
+
def route(http_method, path)
|
|
206
|
+
if !REST::HTTP_METHODS.include?(http_method)
|
|
207
|
+
raise ArgumentError, "#{name}: expected one of #{REST::HTTP_METHODS.map(&:inspect).join(", ")}, but got #{http_method.inspect}"
|
|
208
|
+
end
|
|
209
|
+
if !path.start_with?("/")
|
|
210
|
+
raise ArgumentError, "#{name}: a path must start with \"/\", but got #{path.inspect}"
|
|
211
|
+
end
|
|
212
|
+
@http_method = http_method #: Symbol?
|
|
213
|
+
@path = path #: String?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The HTTP method which this operation is served with. Unless configured with .route,
|
|
217
|
+
# queries are `:get` and mutations are `:post`.
|
|
218
|
+
#
|
|
219
|
+
#: -> Symbol
|
|
220
|
+
def http_method
|
|
221
|
+
@http_method || (query? ? :get : :post)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The path which this operation is served at. Unless configured with .route, it's `/` followed by .public_name.
|
|
225
|
+
#
|
|
226
|
+
#: -> String
|
|
227
|
+
def path
|
|
228
|
+
@path || "/#{public_name}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The name of the single root field selected by this operation.
|
|
232
|
+
#
|
|
233
|
+
#: -> String
|
|
234
|
+
def root_field_name
|
|
235
|
+
query_string
|
|
236
|
+
@root_field_name #: as !nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Every Resource included in this operation, directly or through another resource.
|
|
240
|
+
#
|
|
241
|
+
#: -> Array[singleton(Resource)]
|
|
242
|
+
def resources
|
|
243
|
+
query_string
|
|
244
|
+
@resources #: as !nil
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphQLBacked
|
|
5
|
+
# A reusable selection on a GraphQL Object, Interface or Union type, written as a fragment:
|
|
6
|
+
#
|
|
7
|
+
# class Resources::User < GraphQLBacked::Resource
|
|
8
|
+
# graphql <<~GRAPHQL
|
|
9
|
+
# fragment on User {
|
|
10
|
+
# id
|
|
11
|
+
# name
|
|
12
|
+
# organization { ...Resources::Organization }
|
|
13
|
+
# }
|
|
14
|
+
# GRAPHQL
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Operations (and other Resources) include it by spreading its class name: `...Resources::User`.
|
|
18
|
+
#
|
|
19
|
+
# The fragment must not have a name; in flattened operations it's named after the class (see .fragment_name).
|
|
20
|
+
# A resource must be defined (or autoloadable) before it's referenced.
|
|
21
|
+
# Resources are only parsed when defined. They're validated against a schema as part of each Operation that uses them, when it's added to a Service.
|
|
22
|
+
class Resource
|
|
23
|
+
class << self
|
|
24
|
+
# Assign the GraphQL fragment for this resource.
|
|
25
|
+
#
|
|
26
|
+
#: (String graphql_string) -> void
|
|
27
|
+
def graphql(graphql_string)
|
|
28
|
+
document, resources = ResourceReferences.parse(graphql_string, owner: self)
|
|
29
|
+
definition = document.definitions.first
|
|
30
|
+
if document.definitions.size != 1 || !definition.is_a?(GraphQL::Language::Nodes::FragmentDefinition)
|
|
31
|
+
raise InvalidDocumentError, "#{name}: a Resource must contain exactly one fragment definition"
|
|
32
|
+
end
|
|
33
|
+
if definition.name
|
|
34
|
+
raise InvalidDocumentError, "#{name}: a Resource's fragment must not be named, use `fragment on #{definition.type.name} { ... }` instead of `fragment #{definition.name} on ...`"
|
|
35
|
+
end
|
|
36
|
+
@type_name = definition.type.name #: String?
|
|
37
|
+
@fragment_definition = definition.merge(name: fragment_name) #: GraphQL::Language::Nodes::FragmentDefinition?
|
|
38
|
+
@resources = resources #: Array[singleton(Resource)]?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The name of the GraphQL type that this resource's fragment applies to.
|
|
42
|
+
#
|
|
43
|
+
#: -> String
|
|
44
|
+
def type_name
|
|
45
|
+
@type_name || raise(Error, "#{name} has no GraphQL fragment, add one with `graphql \"...\"`")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The name given to this resource's fragment in flattened operations, eg `Resources__User`.
|
|
49
|
+
#
|
|
50
|
+
#: -> String
|
|
51
|
+
def fragment_name
|
|
52
|
+
(name || raise(Error, "Anonymous Resource classes aren't supported, assign it to a constant")).gsub("::", "__")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# This resource's fragment, named .fragment_name, with references to other resources replaced by their fragment names.
|
|
56
|
+
#
|
|
57
|
+
#: -> GraphQL::Language::Nodes::FragmentDefinition
|
|
58
|
+
def fragment_definition
|
|
59
|
+
@fragment_definition || raise(Error, "#{name} has no GraphQL fragment, add one with `graphql \"...\"`")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Other resources directly referenced by this one.
|
|
63
|
+
#
|
|
64
|
+
#: -> Array[singleton(Resource)]
|
|
65
|
+
def resources
|
|
66
|
+
fragment_definition
|
|
67
|
+
@resources #: as !nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphQLBacked
|
|
5
|
+
# Replaces references to Resource classes in GraphQL strings with valid GraphQL.
|
|
6
|
+
#
|
|
7
|
+
# A reference is a fragment spread whose name is a Ruby constant, eg `...Resources::User`.
|
|
8
|
+
# It's replaced by a spread of that resource's Resource.fragment_name, eg `...Resources__User`.
|
|
9
|
+
module ResourceReferences # :nodoc:
|
|
10
|
+
# Strings and comments are matched first so that anything which looks like a reference inside them is left alone
|
|
11
|
+
PATTERN = /""".*?"""|"(?:\\.|[^"\\\n])*"|\#[^\n]*|(?<spread>\.\.\.\s*)(?<const_path>[A-Z]\w*(?:::[A-Z]\w*)*)/m #: Regexp
|
|
12
|
+
|
|
13
|
+
LOCAL_FRAGMENT = /\bfragment\s+(?!on\b)(\w+)/ #: Regexp
|
|
14
|
+
|
|
15
|
+
# Returns valid GraphQL for `graphql_string`, and the Resource classes it referenced.
|
|
16
|
+
#
|
|
17
|
+
#: (String graphql_string, owner: (singleton(Operation) | singleton(Resource))) -> [String, Array[singleton(Resource)]]
|
|
18
|
+
def self.replace(graphql_string, owner:)
|
|
19
|
+
resources = [] #: Array[singleton(Resource)]
|
|
20
|
+
local_fragment_names = graphql_string.scan(LOCAL_FRAGMENT).flatten
|
|
21
|
+
replaced_string = graphql_string.gsub(PATTERN) do |match|
|
|
22
|
+
const_path = $~[:const_path] #: as String?
|
|
23
|
+
# A spread of a fragment defined in the same string wins, like a local variable would
|
|
24
|
+
next match if const_path.nil? || local_fragment_names.include?(const_path)
|
|
25
|
+
resource = lookup(const_path, owner)
|
|
26
|
+
if resource
|
|
27
|
+
resources << resource unless resources.include?(resource)
|
|
28
|
+
"#{$~[:spread]}#{resource.fragment_name}"
|
|
29
|
+
elsif const_path.include?("::")
|
|
30
|
+
raise InvalidDocumentError, "#{owner.name}: `...#{const_path}` doesn't name a GraphQLBacked::Resource"
|
|
31
|
+
else
|
|
32
|
+
match # GraphQL validation will report this if there's no such fragment
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
[replaced_string, resources]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Like .replace, but also parses the result.
|
|
39
|
+
#
|
|
40
|
+
#: (String graphql_string, owner: (singleton(Operation) | singleton(Resource))) -> [GraphQL::Language::Nodes::Document, Array[singleton(Resource)]]
|
|
41
|
+
def self.parse(graphql_string, owner:)
|
|
42
|
+
replaced_string, resources = replace(graphql_string, owner: owner)
|
|
43
|
+
[GraphQL.parse(replaced_string), resources]
|
|
44
|
+
rescue GraphQL::ParseError => err
|
|
45
|
+
raise InvalidDocumentError, "#{owner.name}: #{err.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve `const_path` (eg `Resources::User`) to a Resource class,
|
|
49
|
+
# searching the namespaces enclosing `owner` first, like Ruby's own constant lookup.
|
|
50
|
+
#
|
|
51
|
+
#: (String const_path, (singleton(Operation) | singleton(Resource)) owner) -> singleton(Resource)?
|
|
52
|
+
def self.lookup(const_path, owner)
|
|
53
|
+
namespaces = owner.name.to_s.split("::")
|
|
54
|
+
namespaces.pop
|
|
55
|
+
namespaces.length.downto(0) do |depth|
|
|
56
|
+
candidate = [*namespaces.first(depth), const_path].join("::")
|
|
57
|
+
next unless Object.const_defined?(candidate)
|
|
58
|
+
const = Object.const_get(candidate)
|
|
59
|
+
return const if const.is_a?(Class) && const < Resource
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphQLBacked
|
|
7
|
+
module REST
|
|
8
|
+
# A Rack app which serves a Service's operations as JSON over HTTP. Build one with REST.app or Service.rest_app.
|
|
9
|
+
class App
|
|
10
|
+
# Requests with these methods don't have variables in their bodies
|
|
11
|
+
NO_BODY_METHODS = ["GET", "DELETE"].freeze #: Array[String]
|
|
12
|
+
|
|
13
|
+
# Where the OpenAPI document is served, if the app was built with `openapi:`
|
|
14
|
+
OPENAPI_PATH = "/openapi.json"
|
|
15
|
+
|
|
16
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze #: Hash[String, String]
|
|
17
|
+
private_constant :JSON_HEADERS
|
|
18
|
+
|
|
19
|
+
# Raised inside #call to respond with an error
|
|
20
|
+
class RequestError < Error # :nodoc:
|
|
21
|
+
#: Integer
|
|
22
|
+
attr_reader :status
|
|
23
|
+
|
|
24
|
+
#: (Integer status, String message) -> void
|
|
25
|
+
def initialize(status, message)
|
|
26
|
+
@status = status
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One operation, and how requests are matched to it
|
|
32
|
+
class Route # :nodoc:
|
|
33
|
+
#: singleton(Operation)
|
|
34
|
+
attr_reader :operation_class
|
|
35
|
+
|
|
36
|
+
#: String
|
|
37
|
+
attr_reader :http_method
|
|
38
|
+
|
|
39
|
+
#: String
|
|
40
|
+
attr_reader :path
|
|
41
|
+
|
|
42
|
+
# The names of the variables in #path
|
|
43
|
+
#
|
|
44
|
+
#: Array[String]
|
|
45
|
+
attr_reader :path_params
|
|
46
|
+
|
|
47
|
+
# The GraphQL type of each of the operation's variables, by name
|
|
48
|
+
#
|
|
49
|
+
#: Hash[String, untyped]
|
|
50
|
+
attr_reader :variable_types
|
|
51
|
+
|
|
52
|
+
# The key of the root field's value in the `"data"` of a response
|
|
53
|
+
#
|
|
54
|
+
#: String
|
|
55
|
+
attr_reader :response_key
|
|
56
|
+
|
|
57
|
+
#: (singleton(Service) service, singleton(Operation) operation_class) -> void
|
|
58
|
+
def initialize(service, operation_class)
|
|
59
|
+
@operation_class = operation_class
|
|
60
|
+
@http_method = operation_class.http_method.to_s.upcase #: String
|
|
61
|
+
@path = operation_class.path #: String
|
|
62
|
+
root_field = operation_class.definition.selections.first #: untyped
|
|
63
|
+
@response_key = root_field.alias || root_field.name #: String
|
|
64
|
+
@variable_types = operation_class.definition.variables.to_h do |variable|
|
|
65
|
+
[variable.name, service.schema.type_from_ast(variable.type)]
|
|
66
|
+
end #: Hash[String, untyped]
|
|
67
|
+
|
|
68
|
+
@path_params = [] #: Array[String]
|
|
69
|
+
segments = @path.split("/").reject(&:empty?).map do |segment|
|
|
70
|
+
next Regexp.escape(segment) unless segment.start_with?(":")
|
|
71
|
+
name = segment.delete_prefix(":")
|
|
72
|
+
if !@variable_types.key?(name)
|
|
73
|
+
raise Error, "#{operation_class.name}'s path (#{@path}) has `:#{name}`, but the operation has no `$#{name}` variable"
|
|
74
|
+
end
|
|
75
|
+
@path_params << name
|
|
76
|
+
"(?<#{name}>[^/]+)"
|
|
77
|
+
end
|
|
78
|
+
@pattern = Regexp.new("\\A/#{segments.join("/")}/?\\z") #: Regexp
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The variables in `path`, if it matches this route
|
|
82
|
+
#
|
|
83
|
+
#: (String path) -> Hash[String, String]?
|
|
84
|
+
def match(path)
|
|
85
|
+
@pattern.match(path)&.named_captures&.transform_values { |value| ::Rack::Utils.unescape_path(value) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Values from the path and query string are strings, so convert them to what their variables expect.
|
|
89
|
+
# Anything which can't be converted is left for GraphQL to report.
|
|
90
|
+
#
|
|
91
|
+
#: (Hash[String, untyped] params) -> Hash[String, untyped]
|
|
92
|
+
def coerce(params)
|
|
93
|
+
params.to_h do |name, value|
|
|
94
|
+
type = @variable_types[name]
|
|
95
|
+
[name, type ? coerce_value(type, value) : value]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
#: (untyped type, untyped value) -> untyped
|
|
102
|
+
def coerce_value(type, value)
|
|
103
|
+
if type.non_null?
|
|
104
|
+
coerce_value(type.of_type, value)
|
|
105
|
+
elsif type.list?
|
|
106
|
+
value.is_a?(Array) ? value.map { |item| coerce_value(type.of_type, item) } : coerce_value(type.of_type, value)
|
|
107
|
+
elsif type.kind.input_object? && value.is_a?(Hash)
|
|
108
|
+
arguments = type.arguments
|
|
109
|
+
value.to_h { |key, item| [key, arguments.key?(key) ? coerce_value(arguments[key].type, item) : item] }
|
|
110
|
+
elsif value.is_a?(String)
|
|
111
|
+
converted = case type.graphql_name
|
|
112
|
+
when "Int" then Integer(value, 10, exception: false)
|
|
113
|
+
when "Float" then Float(value, exception: false)
|
|
114
|
+
when "Boolean" then { "true" => true, "false" => false }[value]
|
|
115
|
+
end #: untyped
|
|
116
|
+
converted.nil? ? value : converted
|
|
117
|
+
else
|
|
118
|
+
value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# See REST.app for these options.
|
|
124
|
+
#
|
|
125
|
+
#: (singleton(Service) service, ?context: untyped, ?prefix: String?, ?openapi: untyped) -> void
|
|
126
|
+
def initialize(service, context: {}, prefix: nil, openapi: nil)
|
|
127
|
+
@openapi = openapi #: untyped
|
|
128
|
+
@service = service
|
|
129
|
+
@context = context #: untyped
|
|
130
|
+
@prefix = prefix&.delete_suffix("/") #: String?
|
|
131
|
+
@routes = service.rest_routes #: Array[Route]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The method and path of each operation:
|
|
135
|
+
#
|
|
136
|
+
# MyService.rest_app.routes # => [["GET", "/users/:id", GetUser], ["POST", "/RenameUser", RenameUser]]
|
|
137
|
+
#
|
|
138
|
+
#: -> Array[[String, String, singleton(Operation)]]
|
|
139
|
+
def routes
|
|
140
|
+
@routes.map { |route| [route.http_method, route.path, route.operation_class] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Handle a request and return a Rack response: `[status, headers, body]`.
|
|
144
|
+
# See REST for how requests are matched to operations and how responses are built.
|
|
145
|
+
#
|
|
146
|
+
#: (Hash[String, untyped] env) -> [Integer, Hash[String, String], Array[String]]
|
|
147
|
+
def call(env)
|
|
148
|
+
request = ::Rack::Request.new(env)
|
|
149
|
+
full_path = request.path_info
|
|
150
|
+
path = full_path
|
|
151
|
+
if @prefix && !@prefix.empty?
|
|
152
|
+
if path != @prefix && !path.start_with?("#{@prefix}/")
|
|
153
|
+
raise RequestError.new(404, "No operation for #{full_path}")
|
|
154
|
+
end
|
|
155
|
+
path = path.delete_prefix(@prefix)
|
|
156
|
+
end
|
|
157
|
+
matches = @routes.filter_map do |route|
|
|
158
|
+
path_params = route.match(path)
|
|
159
|
+
[route, path_params] if path_params
|
|
160
|
+
end
|
|
161
|
+
if matches.empty?
|
|
162
|
+
return [200, JSON_HEADERS.dup, [openapi_json]] if @openapi && path == OPENAPI_PATH && request.get?
|
|
163
|
+
raise RequestError.new(404, "No operation for #{full_path}")
|
|
164
|
+
end
|
|
165
|
+
route, path_params = matches.find { |(route, _path_params)| route.http_method == request.request_method }
|
|
166
|
+
if route.nil? || path_params.nil?
|
|
167
|
+
allow = matches.map { |(route, _path_params)| route.http_method }.uniq.join(", ")
|
|
168
|
+
return [405, JSON_HEADERS.merge("allow" => allow), [JSON.generate(error_body("#{request.request_method} isn't supported for #{full_path}"))]]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
variables = route.coerce(query_params(request)).merge(body_params(request), route.coerce(path_params))
|
|
172
|
+
context = @context.respond_to?(:call) ? @context.call(request) : @context
|
|
173
|
+
response = @service.execute(route.operation_class, variables: variables, context: context)
|
|
174
|
+
if !response.key?("errors")
|
|
175
|
+
[200, JSON_HEADERS.dup, [JSON.generate(response.fetch("data")[route.response_key])]]
|
|
176
|
+
else
|
|
177
|
+
# Without `data`, the operation didn't run, because the variables weren't valid
|
|
178
|
+
[response.key?("data") ? 422 : 400, JSON_HEADERS.dup, [JSON.generate(response)]]
|
|
179
|
+
end
|
|
180
|
+
rescue RequestError => err
|
|
181
|
+
[err.status, JSON_HEADERS.dup, [JSON.generate(error_body(err.message))]]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
#: (::Rack::Request request) -> Hash[String, untyped]
|
|
187
|
+
def query_params(request)
|
|
188
|
+
request.GET
|
|
189
|
+
rescue ::Rack::QueryParser::ParameterTypeError, ::Rack::QueryParser::InvalidParameterError, RangeError
|
|
190
|
+
raise RequestError.new(400, "The query string couldn't be parsed")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
#: (::Rack::Request request) -> Hash[String, untyped]
|
|
194
|
+
def body_params(request)
|
|
195
|
+
return {} if NO_BODY_METHODS.include?(request.request_method)
|
|
196
|
+
input = request.body
|
|
197
|
+
return {} if input.nil?
|
|
198
|
+
# Something else may have read it already, like Rails when it parses params
|
|
199
|
+
input.rewind if input.respond_to?(:rewind)
|
|
200
|
+
body = input.read
|
|
201
|
+
return {} if body.nil? || body.empty?
|
|
202
|
+
params = JSON.parse(body)
|
|
203
|
+
raise RequestError.new(400, "The request body must be a JSON object") unless params.is_a?(Hash)
|
|
204
|
+
params
|
|
205
|
+
rescue JSON::ParserError
|
|
206
|
+
raise RequestError.new(400, "The request body isn't valid JSON")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The service keeps it, so it isn't built again for each app or request
|
|
210
|
+
#
|
|
211
|
+
#: -> String
|
|
212
|
+
def openapi_json
|
|
213
|
+
options = @openapi.is_a?(Hash) ? @openapi : {}
|
|
214
|
+
# Operations' paths in the document don't include the prefix
|
|
215
|
+
options = { servers: [{ url: @prefix }] }.merge(options) if @prefix && !@prefix.empty?
|
|
216
|
+
@service.openapi_json(**options)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Shaped like a GraphQL response, so that every error has the same structure
|
|
220
|
+
#
|
|
221
|
+
#: (String message) -> Hash[String, untyped]
|
|
222
|
+
def error_body(message)
|
|
223
|
+
{ "errors" => [{ "message" => message }] }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|