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,179 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphQLBacked
|
|
7
|
+
module REST
|
|
8
|
+
# Builds an OpenAPI document which describes a Service's REST app. See REST.openapi.
|
|
9
|
+
class OpenAPI # :nodoc:
|
|
10
|
+
VERSION = "3.1.0"
|
|
11
|
+
|
|
12
|
+
# Error responses are shaped like GraphQL responses. See App#call.
|
|
13
|
+
ERROR_CONTENT = {
|
|
14
|
+
"application/json" => {
|
|
15
|
+
"schema" => {
|
|
16
|
+
"type" => "object",
|
|
17
|
+
"properties" => {
|
|
18
|
+
"errors" => {
|
|
19
|
+
"type" => "array",
|
|
20
|
+
"items" => { "type" => "object", "properties" => { "message" => { "type" => "string" } }, "required" => ["message"] },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
"required" => ["errors"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}.freeze #: Hash[String, untyped]
|
|
27
|
+
|
|
28
|
+
RESPONSES = {
|
|
29
|
+
"BadRequest" => { "description" => "The request couldn't be parsed, or its variables weren't valid", "content" => ERROR_CONTENT },
|
|
30
|
+
"UnprocessableContent" => { "description" => "The operation ran, but it returned errors. `data` has the rest of its result.", "content" => ERROR_CONTENT },
|
|
31
|
+
}.freeze #: Hash[String, untyped]
|
|
32
|
+
|
|
33
|
+
#: (singleton(Service) service) -> void
|
|
34
|
+
def initialize(service)
|
|
35
|
+
@service = service
|
|
36
|
+
@scalars = service.scalar_json_schemas #: Hash[String, Hash[String, untyped]]
|
|
37
|
+
@schemas = {} #: Hash[String, untyped]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `info` is merged into the document's `info`, and `document` into the document itself.
|
|
41
|
+
#
|
|
42
|
+
#: (info: Hash[untyped, untyped], document: Hash[untyped, untyped]) -> Hash[String, untyped]
|
|
43
|
+
def to_h(info:, document:)
|
|
44
|
+
paths = {} #: Hash[String, Hash[String, untyped]]
|
|
45
|
+
@service.rest_routes.each do |route|
|
|
46
|
+
path = route.path.split("/").map { |segment| segment.start_with?(":") ? "{#{segment.delete_prefix(":")}}" : segment }.join("/")
|
|
47
|
+
path = "/" if path.empty?
|
|
48
|
+
(paths[path] ||= {})[route.http_method.downcase] = operation(route)
|
|
49
|
+
end
|
|
50
|
+
result = {
|
|
51
|
+
"openapi" => VERSION,
|
|
52
|
+
"info" => { "title" => @service.name || "GraphQLBacked", "version" => "1.0.0" }.merge(JSON.parse(JSON.generate(info))),
|
|
53
|
+
} #: Hash[String, untyped]
|
|
54
|
+
result.merge!(JSON.parse(JSON.generate(document)))
|
|
55
|
+
result["paths"] = paths
|
|
56
|
+
result["components"] = { "schemas" => @schemas, "responses" => RESPONSES }
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
#: (App::Route route) -> Hash[String, untyped]
|
|
63
|
+
def operation(route)
|
|
64
|
+
operation_class = route.operation_class
|
|
65
|
+
input = components(operation_class, JSONSchema.input(@service.schema, operation_class, scalars: @scalars))
|
|
66
|
+
output = components(operation_class, JSONSchema.output(@service.schema, operation_class, scalars: @scalars))
|
|
67
|
+
required = input["required"] || [] #: Array[String]
|
|
68
|
+
has_body = !App::NO_BODY_METHODS.include?(route.http_method)
|
|
69
|
+
|
|
70
|
+
parameters = [] #: Array[Hash[String, untyped]]
|
|
71
|
+
body_properties = {} #: Hash[String, untyped]
|
|
72
|
+
input.fetch("properties").each do |name, json_schema|
|
|
73
|
+
if route.path_params.include?(name)
|
|
74
|
+
parameters << parameter(route, name, "path", true, json_schema)
|
|
75
|
+
elsif has_body
|
|
76
|
+
body_properties[name] = json_schema
|
|
77
|
+
else
|
|
78
|
+
parameters << parameter(route, name, "query", required.include?(name), json_schema)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result = { "operationId" => operation_class.public_name } #: Hash[String, untyped]
|
|
83
|
+
description = operation_class.description || root_field_description(operation_class)
|
|
84
|
+
result["description"] = description if description
|
|
85
|
+
result["parameters"] = parameters if parameters.any?
|
|
86
|
+
if body_properties.any?
|
|
87
|
+
body_schema = { "type" => "object", "properties" => body_properties } #: Hash[String, untyped]
|
|
88
|
+
body_required = required & body_properties.keys
|
|
89
|
+
body_schema["required"] = body_required if body_required.any?
|
|
90
|
+
result["requestBody"] = { "required" => body_required.any?, "content" => { "application/json" => { "schema" => body_schema } } }
|
|
91
|
+
end
|
|
92
|
+
result["responses"] = {
|
|
93
|
+
"200" => {
|
|
94
|
+
"description" => "The result of `#{operation_class.root_field_name}`",
|
|
95
|
+
"content" => { "application/json" => { "schema" => output.fetch("properties").fetch(route.response_key) } },
|
|
96
|
+
},
|
|
97
|
+
"400" => { "$ref" => "#/components/responses/BadRequest" },
|
|
98
|
+
"422" => { "$ref" => "#/components/responses/UnprocessableContent" },
|
|
99
|
+
}
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
#: (singleton(Operation) operation_class) -> String?
|
|
104
|
+
def root_field_description(operation_class)
|
|
105
|
+
root_type = @service.schema.root_type_for_operation(operation_class.operation_type.to_s)
|
|
106
|
+
root_type.get_field(operation_class.root_field_name)&.description
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `location` is `"path"` or `"query"`
|
|
110
|
+
#
|
|
111
|
+
#: (App::Route route, String name, String location, bool required, Hash[String, untyped] json_schema) -> Hash[String, untyped]
|
|
112
|
+
def parameter(route, name, location, required, json_schema)
|
|
113
|
+
result = { "name" => name, "in" => location, "required" => required } #: Hash[String, untyped]
|
|
114
|
+
description = json_schema["description"]
|
|
115
|
+
result["description"] = description if description
|
|
116
|
+
result["schema"] = json_schema.except("description")
|
|
117
|
+
if location == "query"
|
|
118
|
+
type = route.variable_types.fetch(name) #: untyped
|
|
119
|
+
type = type.of_type if type.non_null?
|
|
120
|
+
if type.list?
|
|
121
|
+
# Rack only makes a list from keys which end with `[]`
|
|
122
|
+
result["name"] = "#{name}[]"
|
|
123
|
+
elsif type.kind.input_object?
|
|
124
|
+
# eg `filter[role]=ADMIN`
|
|
125
|
+
result["style"] = "deepObject"
|
|
126
|
+
result["explode"] = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Moves the `$defs` of `json_schema` into this document's shared schemas, and returns `json_schema` referring to them there.
|
|
133
|
+
# Operations usually have the same definition for the same name (an input object or a Resource),
|
|
134
|
+
# but if they don't (eg, for different fragments with the same name), the later one is renamed after its operation.
|
|
135
|
+
#
|
|
136
|
+
#: (singleton(Operation) operation_class, Hash[String, untyped] json_schema) -> Hash[String, untyped]
|
|
137
|
+
def components(operation_class, json_schema)
|
|
138
|
+
defs = json_schema.delete("$defs") || {} #: Hash[String, untyped]
|
|
139
|
+
names = defs.to_h { |name, _definition| [name, name] } #: Hash[String, String]
|
|
140
|
+
renames = {} #: Hash[String, Integer]
|
|
141
|
+
# A definition depends on the names of those it refers to, so go around until no more are renamed
|
|
142
|
+
loop do
|
|
143
|
+
renamed = false #: bool
|
|
144
|
+
defs.each do |name, definition|
|
|
145
|
+
existing = @schemas[names.fetch(name)]
|
|
146
|
+
next if existing.nil? || existing == with_component_refs(definition, names)
|
|
147
|
+
count = renames.fetch(name, 0) + 1
|
|
148
|
+
renames[name] = count
|
|
149
|
+
names[name] = count == 1 ? "#{operation_class.public_name}_#{name}" : "#{operation_class.public_name}_#{name}_#{count}"
|
|
150
|
+
renamed = true
|
|
151
|
+
end
|
|
152
|
+
break if !renamed
|
|
153
|
+
end
|
|
154
|
+
defs.each { |name, definition| @schemas[names.fetch(name)] = with_component_refs(definition, names) }
|
|
155
|
+
with_component_refs(json_schema, names)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A copy of `value` whose references to `$defs` point to this document's shared schemas instead
|
|
159
|
+
#
|
|
160
|
+
#: (untyped value, Hash[String, String] names) -> untyped
|
|
161
|
+
def with_component_refs(value, names)
|
|
162
|
+
case value
|
|
163
|
+
when Hash
|
|
164
|
+
value.to_h do |key, item|
|
|
165
|
+
if key == "$ref" && item.is_a?(String) && item.start_with?("#/$defs/")
|
|
166
|
+
[key, "#/components/schemas/#{names.fetch(item.delete_prefix("#/$defs/"))}"]
|
|
167
|
+
else
|
|
168
|
+
[key, with_component_refs(item, names)]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
when Array
|
|
172
|
+
value.map { |item| with_component_refs(item, names) }
|
|
173
|
+
else
|
|
174
|
+
value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphQLBacked
|
|
5
|
+
# Serves a Service's queries and mutations as JSON over HTTP, and describes them with [OpenAPI](https://spec.openapis.org/oas/v3.1.0).
|
|
6
|
+
#
|
|
7
|
+
# - .app returns a Rack app. It needs the `rack` gem, which isn't a dependency of this one,
|
|
8
|
+
# so add it to your app (`bundle add rack`); without it, .app raises MissingDependencyError.
|
|
9
|
+
# - .openapi returns an OpenAPI document which describes that app
|
|
10
|
+
#
|
|
11
|
+
# Each of them is also available from the service, as Service.rest_app and Service.openapi:
|
|
12
|
+
#
|
|
13
|
+
# # config.ru
|
|
14
|
+
# run MyService.rest_app
|
|
15
|
+
#
|
|
16
|
+
# # or, in Rails' config/routes.rb
|
|
17
|
+
# mount MyService.rest_app, at: "/api"
|
|
18
|
+
#
|
|
19
|
+
# ## Routes
|
|
20
|
+
#
|
|
21
|
+
# By default, queries are served at `GET /PublicName` and mutations at `POST /PublicName` (see Operation.public_name).
|
|
22
|
+
# An Operation can configure its own method and path with Operation.route:
|
|
23
|
+
#
|
|
24
|
+
# class GetUser < GraphQLBacked::Operation
|
|
25
|
+
# route :get, "/users/:id"
|
|
26
|
+
# graphql <<~GRAPHQL
|
|
27
|
+
# query GetUser($id: ID!) {
|
|
28
|
+
# user(id: $id) { ...Resources::User }
|
|
29
|
+
# }
|
|
30
|
+
# GRAPHQL
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # GET /users/1
|
|
34
|
+
# # => 200 { "id": "1", "name": "..." }
|
|
35
|
+
#
|
|
36
|
+
# A service can't have two operations with the same method and path; .app raises Error if it does.
|
|
37
|
+
#
|
|
38
|
+
# ## Variables
|
|
39
|
+
#
|
|
40
|
+
# An operation's variables come from:
|
|
41
|
+
#
|
|
42
|
+
# - the query string, eg `?limit=5&ids[]=1&ids[]=2&filter[role]=ADMIN`
|
|
43
|
+
# - the request body, a JSON object (except for `GET` and `DELETE` requests)
|
|
44
|
+
# - the `:name` segments of its path, which must be variables of the operation
|
|
45
|
+
#
|
|
46
|
+
# When the same variable is given more than once, the path overrides the body, which overrides the query string.
|
|
47
|
+
# Values from the path and query string are strings, so they're converted for `Int`, `Float` and `Boolean` variables
|
|
48
|
+
# (including inside lists and input objects).
|
|
49
|
+
#
|
|
50
|
+
# ## Responses
|
|
51
|
+
#
|
|
52
|
+
# The response is the value of the operation's root field, as JSON.
|
|
53
|
+
#
|
|
54
|
+
# If the GraphQL response has `"errors"`, the response is the whole GraphQL response instead, with status 422,
|
|
55
|
+
# or 400 if the operation didn't run because its variables weren't valid.
|
|
56
|
+
#
|
|
57
|
+
# Other errors have the same shape (`{ "errors": [{ "message": "..." }] }`):
|
|
58
|
+
#
|
|
59
|
+
# - 404 when no operation matches the path
|
|
60
|
+
# - 405 when one does, but not for that method
|
|
61
|
+
# - 400 when the body or query string can't be parsed
|
|
62
|
+
#
|
|
63
|
+
# ## GraphQL context
|
|
64
|
+
#
|
|
65
|
+
# `context:` is the GraphQL context to execute with. To use a different one for each request,
|
|
66
|
+
# pass something that responds to `call`. It receives the `Rack::Request`:
|
|
67
|
+
#
|
|
68
|
+
# MyService.rest_app(context: ->(request) { { current_user: User.from_token(request.get_header("HTTP_AUTHORIZATION")) } })
|
|
69
|
+
#
|
|
70
|
+
# ## Rails controllers
|
|
71
|
+
#
|
|
72
|
+
# To serve operations from a Rails controller instead (for example, to use the controller's authentication),
|
|
73
|
+
# route every request under a path to one action, and build an app for each request.
|
|
74
|
+
# `prefix:` is the part of the path which comes before operations' paths:
|
|
75
|
+
#
|
|
76
|
+
# # config/routes.rb
|
|
77
|
+
# match "/api/*path", to: "api#show", via: :all
|
|
78
|
+
#
|
|
79
|
+
# # app/controllers/api_controller.rb
|
|
80
|
+
# class ApiController < ApplicationController
|
|
81
|
+
# def show
|
|
82
|
+
# app = MyService.rest_app(context: { current_user: current_user }, prefix: "/api")
|
|
83
|
+
# status, headers, body = app.call(request.env)
|
|
84
|
+
# response.headers.merge!(headers)
|
|
85
|
+
# render json: body.join, status: status
|
|
86
|
+
# end
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# Routes are built once per service, so building an app for each request is cheap.
|
|
90
|
+
# For `POST`, `PUT` and `PATCH` requests, the controller will need `skip_forgery_protection` (or `ActionController::API`).
|
|
91
|
+
#
|
|
92
|
+
# ## OpenAPI
|
|
93
|
+
#
|
|
94
|
+
# .openapi returns an OpenAPI 3.1 document as a Hash. To serve it at `/openapi.json`,
|
|
95
|
+
# pass `openapi: true` to .app, or the options for .openapi:
|
|
96
|
+
#
|
|
97
|
+
# MyService.rest_app(openapi: { info: { title: "My API", version: "1.2.0" } })
|
|
98
|
+
#
|
|
99
|
+
# The service keeps the document (and its JSON) until its operations, scalars or the options change,
|
|
100
|
+
# so it isn't built again for each request, even when an app is built for each request.
|
|
101
|
+
# Service.openapi_json returns that JSON, to serve it some other way.
|
|
102
|
+
#
|
|
103
|
+
# When the app has a `prefix:`, it's the document's server URL, unless you pass `servers:`.
|
|
104
|
+
#
|
|
105
|
+
# For each operation:
|
|
106
|
+
#
|
|
107
|
+
# - its `operationId` is Operation.public_name
|
|
108
|
+
# - its description is Operation.description, or else the description of its root field in the schema
|
|
109
|
+
# - variables in its path are `path` parameters
|
|
110
|
+
# - its other variables are `query` parameters for `GET` and `DELETE` operations, or else properties of the JSON request body.
|
|
111
|
+
# List parameters are named with `[]` (`ids[]=1&ids[]=2`) and input objects use the `deepObject` style (`filter[role]=ADMIN`).
|
|
112
|
+
# Each one is described by the argument that it's passed to.
|
|
113
|
+
# - its `200` response is described by the selections of its root field, with the description of each field
|
|
114
|
+
# - input objects and Resources are shared in `components.schemas`, named after the input type or Resource.fragment_name
|
|
115
|
+
# (eg `Resources__User`). If two operations define different fragments with the same name, the second
|
|
116
|
+
# is named after its operation (eg `ListUsers_Friend`).
|
|
117
|
+
# - custom scalars are described by Service.scalar_json_schema
|
|
118
|
+
module REST
|
|
119
|
+
# The HTTP methods which Operation.route accepts
|
|
120
|
+
HTTP_METHODS = [:get, :post, :put, :patch, :delete].freeze #: Array[Symbol]
|
|
121
|
+
|
|
122
|
+
# A Rack app which serves the operations of `service`. See the sections above for how it handles requests.
|
|
123
|
+
#
|
|
124
|
+
# - `context` is the GraphQL context to execute with, or something that responds to `call`,
|
|
125
|
+
# which receives the `Rack::Request` and returns the context for that request
|
|
126
|
+
# - `prefix` is the part of each request's path which comes before operations' paths, eg `"/api"`
|
|
127
|
+
# - `openapi` serves .openapi at `/openapi.json`. It's `true`, or a Hash of options for .openapi.
|
|
128
|
+
#
|
|
129
|
+
# GraphQLBacked::REST.app(MyService, context: { current_user: user }, prefix: "/api", openapi: true)
|
|
130
|
+
#
|
|
131
|
+
#: (singleton(Service) service, ?context: untyped, ?prefix: String?, ?openapi: untyped) -> App
|
|
132
|
+
def self.app(service, context: {}, prefix: nil, openapi: nil)
|
|
133
|
+
App.new(service, context: context, prefix: prefix, openapi: openapi)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# An OpenAPI 3.1 document which describes the .app of `service`, as a Hash with String keys.
|
|
137
|
+
# See the "OpenAPI" section above for how operations are described.
|
|
138
|
+
#
|
|
139
|
+
# `info` is merged into the document's `info`. By default, its `title` is the service's name and its `version` is `1.0.0`.
|
|
140
|
+
# Other options are merged into the document itself:
|
|
141
|
+
#
|
|
142
|
+
# GraphQLBacked::REST.openapi(MyService, info: { title: "My API", version: "1.2.0" }, servers: [{ url: "https://example.com/api" }])
|
|
143
|
+
#
|
|
144
|
+
# This builds a new document each time; Service.openapi keeps it until the service or the options change.
|
|
145
|
+
#
|
|
146
|
+
#: (singleton(Service) service, ?info: Hash[untyped, untyped], **untyped document) -> Hash[String, untyped]
|
|
147
|
+
def self.openapi(service, info: {}, **document)
|
|
148
|
+
OpenAPI.new(service).to_h(info: info, document: document)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# How each operation of `service` is matched to requests. This builds them each time;
|
|
152
|
+
# Service.rest_routes keeps them until the service changes.
|
|
153
|
+
#
|
|
154
|
+
#: (singleton(Service) service) -> Array[App::Route]
|
|
155
|
+
def self.routes(service) # :nodoc:
|
|
156
|
+
require_rack
|
|
157
|
+
routes = service.operations.map { |operation_class| App::Route.new(service, operation_class) }
|
|
158
|
+
routes.group_by { |route| [route.http_method, route.path] }.each do |(http_method, path), same_routes|
|
|
159
|
+
next if same_routes.size == 1
|
|
160
|
+
names = same_routes.map { |route| route.operation_class.name }.join(" and ")
|
|
161
|
+
raise Error, "#{service.name} has more than one operation for #{http_method} #{path}: #{names}"
|
|
162
|
+
end
|
|
163
|
+
routes
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# `rack` isn't a dependency of this gem, so it's only required when REST features are first used.
|
|
167
|
+
#
|
|
168
|
+
#: -> void
|
|
169
|
+
def self.require_rack # :nodoc:
|
|
170
|
+
require "rack"
|
|
171
|
+
rescue LoadError => err
|
|
172
|
+
raise unless err.path == "rack"
|
|
173
|
+
raise MissingDependencyError, "GraphQLBacked's REST support requires the `rack` gem, add it to your app with `bundle add rack`"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private_class_method :require_rack
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
require "graphql_backed/rest/app"
|
|
181
|
+
require "graphql_backed/rest/open_api"
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphQLBacked
|
|
7
|
+
# A set of Operations backed by one GraphQL schema:
|
|
8
|
+
#
|
|
9
|
+
# class MyService < GraphQLBacked::Service
|
|
10
|
+
# schema MySchema
|
|
11
|
+
# operation GetUser
|
|
12
|
+
# operation RenameUser
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# MyService.execute(GetUser, variables: { id: "1" })
|
|
16
|
+
# # => { "data" => { "user" => { "id" => "1", "name" => "..." } } }
|
|
17
|
+
#
|
|
18
|
+
# Operations are identified by Operation.public_name, which must be unique within a service.
|
|
19
|
+
# Each operation is validated against .schema when it's added, raising InvalidDocumentError if it isn't valid.
|
|
20
|
+
# That's the only time it's validated: .execute runs it without validating it again, unless the service has `validate true`.
|
|
21
|
+
#
|
|
22
|
+
# A service can also serve its operations as Model Context Protocol tools (see GraphQLBacked::MCP)
|
|
23
|
+
# or as JSON over HTTP (see GraphQLBacked::REST).
|
|
24
|
+
class Service
|
|
25
|
+
class << self
|
|
26
|
+
# Get or set the schema that this service's operations are validated against and run by. Inherited by subclasses.
|
|
27
|
+
# Setting it validates the operations which this service already has (like the ones inherited from its superclass).
|
|
28
|
+
#
|
|
29
|
+
#: (?singleton(GraphQL::Schema)? new_schema) -> singleton(GraphQL::Schema)
|
|
30
|
+
def schema(new_schema = nil)
|
|
31
|
+
if new_schema
|
|
32
|
+
operations.each { |operation_class| validate_operation(operation_class, new_schema) }
|
|
33
|
+
@schema = new_schema #: singleton(GraphQL::Schema)?
|
|
34
|
+
end
|
|
35
|
+
@schema || parent_service&.schema || raise(Error, "#{name} has no schema, add one with `schema MySchema`")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Configure whether .execute validates operations again, each time they're run:
|
|
39
|
+
#
|
|
40
|
+
# validate true
|
|
41
|
+
#
|
|
42
|
+
# It's `false` by default, because operations were already validated when they were added. Turn it on when a document's validity
|
|
43
|
+
# depends on the request, for example when .schema hides some of its types or fields based on `context`. Inherited by subclasses.
|
|
44
|
+
#
|
|
45
|
+
#: (bool new_validate) -> void
|
|
46
|
+
def validate(new_validate)
|
|
47
|
+
@validate = new_validate #: bool?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether .execute validates operations again, see .validate.
|
|
51
|
+
#
|
|
52
|
+
#: -> bool
|
|
53
|
+
def validate?
|
|
54
|
+
if @validate.nil?
|
|
55
|
+
parent_service&.validate? || false
|
|
56
|
+
else
|
|
57
|
+
@validate
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Add an operation to this service, validating its flattened document against .schema.
|
|
62
|
+
# Raises if the service already has a different operation with the same Operation.public_name.
|
|
63
|
+
#
|
|
64
|
+
#: (singleton(Operation) operation_class) -> void
|
|
65
|
+
def operation(operation_class)
|
|
66
|
+
validate_operation(operation_class, schema)
|
|
67
|
+
existing = find_operation(operation_class.public_name)
|
|
68
|
+
return if existing == operation_class
|
|
69
|
+
if existing
|
|
70
|
+
raise Error, "#{name} already has an operation named #{existing.public_name} (#{existing.name}), so #{operation_class.name} can't be added"
|
|
71
|
+
end
|
|
72
|
+
@own_operations ||= [] #: Array[singleton(Operation)]?
|
|
73
|
+
@own_operations << operation_class
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Describe one of .schema's custom scalars with JSON Schema, for frontends which describe their inputs and outputs that way (MCP tools and OpenAPI documents):
|
|
77
|
+
#
|
|
78
|
+
# scalar_json_schema Types::Money, { "type" => "string", "pattern" => "^\\d+\\.\\d{2}$" }
|
|
79
|
+
# scalar_json_schema Types::Percent, { "type" => "number", "minimum" => 0, "maximum" => 100 }
|
|
80
|
+
#
|
|
81
|
+
# `scalar_class` is the scalar's class, a subclass of `GraphQL::Schema::Scalar`. Inherited by subclasses.
|
|
82
|
+
#
|
|
83
|
+
# GraphQL-Ruby's own `ISO8601Date`, `ISO8601DateTime`, `ISO8601Duration` and `BigInt` are already described.
|
|
84
|
+
# Any other scalar which isn't configured here accepts any JSON value.
|
|
85
|
+
#
|
|
86
|
+
# Those defaults, and the ones for `String`, `ID`, `Int`, `Float` and `Boolean`, can be overridden by passing the built-in class:
|
|
87
|
+
#
|
|
88
|
+
# scalar_json_schema GraphQL::Types::ID, { "type" => "string", "pattern" => "^\\d+$" }
|
|
89
|
+
#
|
|
90
|
+
# An override replaces the default altogether, for variables and for output alike: with this one,
|
|
91
|
+
# `ID` variables aren't accepted as integers anymore.
|
|
92
|
+
#
|
|
93
|
+
#: (singleton(GraphQL::Schema::Scalar) scalar_class, Hash[untyped, untyped] json_schema) -> void
|
|
94
|
+
def scalar_json_schema(scalar_class, json_schema)
|
|
95
|
+
scalar = scalar_class #: untyped
|
|
96
|
+
if !(scalar.is_a?(Class) && scalar < GraphQL::Schema::Scalar)
|
|
97
|
+
raise ArgumentError, "#{name}: expected a subclass of GraphQL::Schema::Scalar, but got #{scalar.inspect}"
|
|
98
|
+
end
|
|
99
|
+
@own_scalar_json_schemas ||= {} #: Hash[String, Hash[String, untyped]]?
|
|
100
|
+
# Round-tripping makes a copy with string keys
|
|
101
|
+
@own_scalar_json_schemas[scalar_class.graphql_name] = JSON.parse(JSON.generate(json_schema))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The schemas configured with .scalar_json_schema, by scalar name, including those inherited from its superclass.
|
|
105
|
+
#
|
|
106
|
+
#: -> Hash[String, Hash[String, untyped]]
|
|
107
|
+
def scalar_json_schemas
|
|
108
|
+
(parent_service&.scalar_json_schemas || {}).merge(@own_scalar_json_schemas || {})
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Find one of this service's operations by its Operation.public_name.
|
|
112
|
+
#
|
|
113
|
+
# MyService.find_operation("GetUser") # => GetUser
|
|
114
|
+
#
|
|
115
|
+
#: (String public_name) -> singleton(Operation)?
|
|
116
|
+
def find_operation(public_name)
|
|
117
|
+
operations.find { |operation_class| operation_class.public_name == public_name }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The operations added to this service, including those inherited from its superclass.
|
|
121
|
+
#
|
|
122
|
+
#: -> Array[singleton(Operation)]
|
|
123
|
+
def operations
|
|
124
|
+
(parent_service&.operations || []) + (@own_operations || [])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Run one of this service's operations and return the GraphQL response (with `"data"` and/or `"errors"`).
|
|
128
|
+
# This is the only place where GraphQL is executed.
|
|
129
|
+
#
|
|
130
|
+
# MyService.execute(GetUser, variables: { id: "1" }, context: { current_user: current_user })
|
|
131
|
+
# # => { "data" => { "user" => { "id" => "1", "name" => "..." } } }
|
|
132
|
+
#
|
|
133
|
+
#: (singleton(Operation) operation_class, ?variables: Hash[untyped, untyped], ?context: Hash[untyped, untyped]) -> Hash[String, untyped]
|
|
134
|
+
def execute(operation_class, variables: {}, context: {})
|
|
135
|
+
if !operations.include?(operation_class)
|
|
136
|
+
raise Error, "#{operation_class.name} isn't part of #{name}, add it with `operation #{operation_class.name}`"
|
|
137
|
+
end
|
|
138
|
+
# Passing the document saves GraphQL-Ruby from parsing the query string each time,
|
|
139
|
+
# and it was already validated when it was added to this service
|
|
140
|
+
schema.execute(document: operation_class.document, variables: variables, context: context, validate: validate?).to_h
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# This service's operations as `MCP::Tool` classes, for the `mcp` gem. See MCP.tools.
|
|
144
|
+
#
|
|
145
|
+
# They're built once, then again whenever operations or scalars are added to this service (or its superclass).
|
|
146
|
+
#
|
|
147
|
+
#: -> Array[singleton(::MCP::Tool)]
|
|
148
|
+
def mcp_tools
|
|
149
|
+
current_config = [operations, scalar_json_schemas]
|
|
150
|
+
if @mcp_tools.nil? || @mcp_tools_config != current_config
|
|
151
|
+
@mcp_tools = MCP.tools(self) #: Array[singleton(::MCP::Tool)]?
|
|
152
|
+
@mcp_tools_config = current_config #: [Array[singleton(Operation)], Hash[String, Hash[String, untyped]]]?
|
|
153
|
+
end
|
|
154
|
+
@mcp_tools #: as !nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# An `MCP::Server` with .mcp_tools. `context` is the GraphQL context which its tools execute with;
|
|
158
|
+
# other options are passed to `MCP::Server.new`. See MCP.server.
|
|
159
|
+
#
|
|
160
|
+
# MyService.mcp_server(context: { current_user: user }, name: "my-api", version: "1.2.0")
|
|
161
|
+
#
|
|
162
|
+
#: (?context: Hash[untyped, untyped], **untyped server_options) -> ::MCP::Server
|
|
163
|
+
def mcp_server(context: {}, **server_options)
|
|
164
|
+
MCP.server(self, context: context, **server_options)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A Streamable HTTP transport for .mcp_server, which is also a Rack app. See MCP.transport for its options.
|
|
168
|
+
#
|
|
169
|
+
# # config.ru
|
|
170
|
+
# run MyService.mcp_transport(allowed_hosts: ["api.example.com"])
|
|
171
|
+
#
|
|
172
|
+
#: (?context: Hash[untyped, untyped], ?server_options: Hash[Symbol, untyped], **untyped transport_options) -> ::MCP::Server::Transports::StreamableHTTPTransport
|
|
173
|
+
def mcp_transport(context: {}, server_options: {}, **transport_options)
|
|
174
|
+
MCP.transport(self, context: context, server_options: server_options, **transport_options)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A Rack app which serves this service's operations as JSON over HTTP. See REST.app for its options.
|
|
178
|
+
#
|
|
179
|
+
# # config.ru
|
|
180
|
+
# run MyService.rest_app(context: { current_user: user }, prefix: "/api", openapi: true)
|
|
181
|
+
#
|
|
182
|
+
#: (?context: untyped, ?prefix: String?, ?openapi: untyped) -> REST::App
|
|
183
|
+
def rest_app(context: {}, prefix: nil, openapi: nil)
|
|
184
|
+
REST.app(self, context: context, prefix: prefix, openapi: openapi)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# An OpenAPI 3.1 document which describes .rest_app, as a Hash with String keys.
|
|
188
|
+
# `info` is merged into the document's `info`; other options are merged into the document. See REST.openapi.
|
|
189
|
+
#
|
|
190
|
+
# MyService.openapi(info: { title: "My API", version: "1.2.0" }, servers: [{ url: "https://example.com/api" }])
|
|
191
|
+
#
|
|
192
|
+
# It's built once, then again whenever it's called with different options, or operations or scalars
|
|
193
|
+
# are added to this service (or its superclass). It's frozen, since it's shared between calls.
|
|
194
|
+
#
|
|
195
|
+
#: (?info: Hash[untyped, untyped], **untyped document) -> Hash[String, untyped]
|
|
196
|
+
def openapi(info: {}, **document)
|
|
197
|
+
current_config = [rest_routes, scalar_json_schemas, info, document]
|
|
198
|
+
if @openapi.nil? || @openapi_config != current_config
|
|
199
|
+
@openapi = deep_freeze(REST.openapi(self, info: info, **document)) #: Hash[String, untyped]?
|
|
200
|
+
@openapi_json = nil #: String?
|
|
201
|
+
@openapi_config = current_config #: Array[untyped]?
|
|
202
|
+
end
|
|
203
|
+
@openapi #: as !nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# .openapi as a JSON string, for serving it. It takes the same options, and it's kept in the same way.
|
|
207
|
+
#
|
|
208
|
+
#: (?info: Hash[untyped, untyped], **untyped document) -> String
|
|
209
|
+
def openapi_json(info: {}, **document)
|
|
210
|
+
current_document = openapi(info: info, **document)
|
|
211
|
+
@openapi_json ||= JSON.generate(current_document).freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# How each operation is matched to requests by .rest_app. They're kept, so building an app for each request is cheap.
|
|
215
|
+
#
|
|
216
|
+
#: -> Array[REST::App::Route]
|
|
217
|
+
def rest_routes # :nodoc:
|
|
218
|
+
current_config = operations.map { |operation_class| [operation_class, operation_class.http_method, operation_class.path] }
|
|
219
|
+
# Build again if operations were added (here or in a superclass) or routed differently since last time
|
|
220
|
+
if @rest_routes.nil? || @rest_routes_config != current_config
|
|
221
|
+
@rest_routes = REST.routes(self) #: Array[REST::App::Route]?
|
|
222
|
+
@rest_routes_config = current_config #: Array[[singleton(Operation), Symbol, String]]?
|
|
223
|
+
end
|
|
224
|
+
@rest_routes #: as !nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
#: (singleton(Operation) operation_class, singleton(GraphQL::Schema) target_schema) -> void
|
|
230
|
+
def validate_operation(operation_class, target_schema)
|
|
231
|
+
errors = target_schema.validate(operation_class.document)
|
|
232
|
+
if errors.any?
|
|
233
|
+
raise InvalidDocumentError, "#{operation_class.name} isn't valid for #{name}: #{errors.map(&:message).join(", ")}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
#: (untyped value) -> untyped
|
|
238
|
+
def deep_freeze(value)
|
|
239
|
+
case value
|
|
240
|
+
when Hash then value.each_value { |item| deep_freeze(item) }
|
|
241
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
242
|
+
end
|
|
243
|
+
value.freeze
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
#: -> singleton(Service)?
|
|
247
|
+
def parent_service
|
|
248
|
+
parent = superclass
|
|
249
|
+
parent if parent.is_a?(Class) && parent < Service
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
require "graphql_backed/version"
|
|
6
|
+
|
|
7
|
+
# Build APIs on top of existing GraphQL APIs:
|
|
8
|
+
#
|
|
9
|
+
# - a Resource is a reusable GraphQL fragment
|
|
10
|
+
# - an Operation is a query or mutation which includes resources
|
|
11
|
+
# - a Service is a set of operations backed by a schema. It runs them.
|
|
12
|
+
# - MCP serves a service's operations as Model Context Protocol tools
|
|
13
|
+
# - REST serves a service's operations as JSON over HTTP, and describes them with OpenAPI
|
|
14
|
+
module GraphQLBacked
|
|
15
|
+
# Base class for errors raised by this library.
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when an Operation or Resource is defined with GraphQL that can't be parsed,
|
|
19
|
+
# doesn't have the required structure, or isn't valid for a Service's schema.
|
|
20
|
+
class InvalidDocumentError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a feature needs a gem which isn't a dependency of this one, and that gem isn't available.
|
|
23
|
+
class MissingDependencyError < Error; end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require "graphql_backed/resource_references"
|
|
27
|
+
require "graphql_backed/resource"
|
|
28
|
+
require "graphql_backed/operation"
|
|
29
|
+
require "graphql_backed/json_schema"
|
|
30
|
+
require "graphql_backed/mcp"
|
|
31
|
+
require "graphql_backed/rest"
|
|
32
|
+
require "graphql_backed/service"
|