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,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,7 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # :startdoc:
5
+ module GraphQLBacked
6
+ VERSION = "0.1.0" #: String
7
+ 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"