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.
@@ -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