diff --git a/Gemfile.d/app.rb b/Gemfile.d/app.rb
index c0d501f73fb..ff389ad3f00 100644
--- a/Gemfile.d/app.rb
+++ b/Gemfile.d/app.rb
@@ -69,6 +69,7 @@ gem 'encrypted_cookie_store-instructure', '1.2.11', require: 'encrypted_cookie_s
gem 'folio-pagination', '0.0.12', require: 'folio/rails'
gem 'ffi', '1.13.1', require: false
gem 'gepub', '1.0.13'
+gem 'apollo-federation', '1.1.5'
gem 'graphql', '1.12.14'
gem 'graphql-batch', '0.4.3'
gem 'hashery', '2.1.2', require: false
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index d0fc40adce2..55e1a3d6866 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -25,9 +25,29 @@ CanvasSchema.graphql_definition
class GraphQLController < ApplicationController
include Api::V1
+ before_action :require_inst_access_token_auth, only: :subgraph_execute
before_action :require_user, if: :require_auth?
+ # This action is for use only with the federated API Gateway. See
+ # `app/graphql/README.md` for details.
+ def subgraph_execute
+ result = execute_on(CanvasSchema.for_federation)
+ render json: result
+ end
+
def execute
+ result = execute_on(CanvasSchema)
+ render json: result
+ end
+
+ def graphiql
+ @page_title = "GraphiQL"
+ render :graphiql, layout: 'bare'
+ end
+
+ private
+
+ def execute_on(schema)
query = params[:query]
variables = params[:variables] || {}
context = {
@@ -47,23 +67,13 @@ class GraphQLController < ApplicationController
)
]
}
- result = nil
overall_timeout = Setting.get('graphql_overall_timeout', '60').to_i.seconds
Timeout.timeout(overall_timeout) do
- result = CanvasSchema.execute(query, variables: variables, context: context)
+ schema.execute(query, variables: variables, context: context)
end
-
- render json: result
end
- def graphiql
- @page_title = "GraphiQL"
- render :graphiql, layout: 'bare'
- end
-
- private
-
def require_auth?
if action_name == 'execute'
return !::Account.site_admin.feature_enabled?(:disable_graphql_authentication)
@@ -71,4 +81,13 @@ class GraphQLController < ApplicationController
true
end
+
+ def require_inst_access_token_auth
+ unless @authenticated_with_inst_access_token
+ render(
+ json: {error: "InstAccess token auth required"},
+ status: 401
+ )
+ end
+ end
end
diff --git a/app/graphql/README.md b/app/graphql/README.md
new file mode 100644
index 00000000000..a1700e7c0e8
--- /dev/null
+++ b/app/graphql/README.md
@@ -0,0 +1,44 @@
+# GraphQL in Canvas
+
+Canvas has a "first-class" GraphQL data graph that is publicly exposed on an
+API endpoint. This is already [well-documented](../../doc/api/graphql.md).
+
+## Apollo Federation
+
+In addition to the standard GraphQL endpoint, Canvas exposes a "subgraph"
+endpoint whose schema is suitable for use in an [Apollo
+Federation](https://www.apollographql.com/docs/federation/). This is the same
+schema, but extended according to the Apollo Federation
+[specification](https://www.apollographql.com/docs/federation/federation-spec/),
+and with some Federation directives applied to various fields and types.
+
+The [apollo-federation gem](https://github.com/Gusto/apollo-federation-ruby) is
+used to add Federation directives to this subgraph. While it is important that
+the public-facing graph does not include Federation extensions, the gem's
+features can be used freely on any type or field. They are simply ignored in
+the public-facing graph, and do not show up in its schema.
+
+### Promoting an Object Type to a Federation Entity
+
+A Federation [entity](https://www.apollographql.com/docs/federation/entities)
+is an object type whose definition spans multiple subgraphs. One subgraph
+provides its canonical definition, and others extend it.
+
+To promote an existing type to an entity with its canonical definition in
+Canvas, declare one or more `key` fields and implement `::resolve_reference`.
+E.g.:
+
+```ruby
+module Types
+ class CourseType < ApplicationObjectType
+ key fields: "id"
+ def self.resolve_reference(reference, context)
+ GraphQLNodeLoader.load("Course", reference[:id], context)
+ end
+ end
+end
+```
+
+See [the gem usage docs](https://github.com/Gusto/apollo-federation-ruby#usage)
+for more examples and guidance on using Federation features, including how to
+extend entities whose canonical definition resides in an external subgraph.
diff --git a/app/graphql/canvas_schema.rb b/app/graphql/canvas_schema.rb
index f50127d37b6..c4eb7888f78 100644
--- a/app/graphql/canvas_schema.rb
+++ b/app/graphql/canvas_schema.rb
@@ -98,4 +98,10 @@ class CanvasSchema < GraphQL::Schema
orphan_types [Types::PageType, Types::FileType, Types::ExternalUrlType,
Types::ExternalToolType, Types::ModuleExternalToolType,
Types::ProgressType, Types::ModuleSubHeaderType]
+
+ def self.for_federation
+ @federatable_schema ||= Class.new(CanvasSchema) do
+ include ApolloFederation::Schema
+ end
+ end
end
diff --git a/app/graphql/interfaces/asset_string_interface.rb b/app/graphql/interfaces/asset_string_interface.rb
index 7f90223ec82..98a05b32e1e 100644
--- a/app/graphql/interfaces/asset_string_interface.rb
+++ b/app/graphql/interfaces/asset_string_interface.rb
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see .
module Interfaces::AssetStringInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
graphql_name "AssetString"
diff --git a/app/graphql/interfaces/assignments_connection_interface.rb b/app/graphql/interfaces/assignments_connection_interface.rb
index 2d8bfc40d9b..fce3495c7d4 100644
--- a/app/graphql/interfaces/assignments_connection_interface.rb
+++ b/app/graphql/interfaces/assignments_connection_interface.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Interfaces::AssignmentsConnectionInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
class AssignmentFilterInputType < Types::BaseInputObject
graphql_name "AssignmentFilter"
diff --git a/app/graphql/interfaces/base_interface.rb b/app/graphql/interfaces/base_interface.rb
new file mode 100644
index 00000000000..bfdbeb53857
--- /dev/null
+++ b/app/graphql/interfaces/base_interface.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2021 - present Instructure, Inc.
+#
+# This file is part of Canvas.
+#
+# Canvas is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, version 3 of the License.
+#
+# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see .
+
+# Create a GraphQL interface (https://graphql-ruby.org/type_definitions/interfaces)
+# by including this module in your interface module. In addition to the standard
+# GraphQL field declarations, this will allow you to attach `@key` and `@external`
+# directives to your interface for integration with other services' data graphs in a
+# federated supergraph. See: https://github.com/Gusto/apollo-federation-ruby#usage
+module Interfaces::BaseInterface
+ include GraphQL::Schema::Interface
+ include ApolloFederation::Interface
+
+ field_class Types::BaseField
+end
diff --git a/app/graphql/interfaces/legacy_id_interface.rb b/app/graphql/interfaces/legacy_id_interface.rb
index 734bc97bb51..03d87c6f66d 100644
--- a/app/graphql/interfaces/legacy_id_interface.rb
+++ b/app/graphql/interfaces/legacy_id_interface.rb
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see .
module Interfaces::LegacyIDInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
field :_id, ID, "legacy canvas id", method: :id, null: false
end
diff --git a/app/graphql/interfaces/module_item_interface.rb b/app/graphql/interfaces/module_item_interface.rb
index 1f6100dd5c6..507eba25a79 100644
--- a/app/graphql/interfaces/module_item_interface.rb
+++ b/app/graphql/interfaces/module_item_interface.rb
@@ -17,7 +17,8 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see .
module Interfaces::ModuleItemInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
+
description "An item that can be in context modules"
field :modules, [Types::ModuleType], null: true
diff --git a/app/graphql/interfaces/submission_interface.rb b/app/graphql/interfaces/submission_interface.rb
index e6e1cb0af24..4cec093cb11 100644
--- a/app/graphql/interfaces/submission_interface.rb
+++ b/app/graphql/interfaces/submission_interface.rb
@@ -74,7 +74,8 @@ class SubmissionRubricAssessmentFilterInputType < Types::BaseInputObject
end
module Interfaces::SubmissionInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
+
description 'Types for submission or submission history'
class LatePolicyStatusType < Types::BaseEnum
diff --git a/app/graphql/interfaces/timestamp_interface.rb b/app/graphql/interfaces/timestamp_interface.rb
index ce63f611796..6b3e4dcc4f2 100644
--- a/app/graphql/interfaces/timestamp_interface.rb
+++ b/app/graphql/interfaces/timestamp_interface.rb
@@ -20,7 +20,7 @@
module Interfaces
module TimestampInterface
- include GraphQL::Schema::Interface
+ include Interfaces::BaseInterface
graphql_name "Timestamped"
diff --git a/app/graphql/types/application_object_type.rb b/app/graphql/types/application_object_type.rb
index 6d43a24a91d..766f7d84dad 100644
--- a/app/graphql/types/application_object_type.rb
+++ b/app/graphql/types/application_object_type.rb
@@ -20,6 +20,10 @@
module Types
class ApplicationObjectType < GraphQL::Schema::Object
+ include ApolloFederation::Object
+
+ field_class BaseField
+
# this is using graphql-ruby's built-in authorization framework
#
# we are purposely not using it anywhere else in the app for performance
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
new file mode 100644
index 00000000000..0bae7bbf9c2
--- /dev/null
+++ b/app/graphql/types/base_field.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2021 - present Instructure, Inc.
+#
+# This file is part of Canvas.
+#
+# Canvas is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, version 3 of the License.
+#
+# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see .
+#
+
+class Types::BaseField < GraphQL::Schema::Field
+ include ApolloFederation::Field
+end
diff --git a/app/graphql/types/course_type.rb b/app/graphql/types/course_type.rb
index af876913bc2..1d65b7f45ca 100644
--- a/app/graphql/types/course_type.rb
+++ b/app/graphql/types/course_type.rb
@@ -80,6 +80,11 @@ module Types
implements Interfaces::TimestampInterface
implements Interfaces::LegacyIDInterface
+ key fields: "id"
+ def self.resolve_reference(reference, context)
+ GraphQLNodeLoader.load("Course", reference[:id], context)
+ end
+
global_id_field :id
field :name, String, null: false
field :course_code, String, "course short name", null: true
diff --git a/config/routes.rb b/config/routes.rb
index 7c84adf5df8..eb644ff5dba 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -26,6 +26,9 @@ Dir["{gems,vendor}/plugins/*/config/pre_routes.rb"].each { |pre_routes|
CanvasRails::Application.routes.draw do
post "/api/graphql", to: "graphql#execute"
+ post "/api/graphql/subgraph", to: "graphql#subgraph_execute"
+ # The subgraph endpoint is for use only with the federated API Gateway. See
+ # `app/graphql/README.md` for details.
get 'graphiql', to: 'graphql#graphiql'
resources :submissions, only: [] do
diff --git a/lib/authentication_methods.rb b/lib/authentication_methods.rb
index 615402c5a2b..d24cec5365b 100644
--- a/lib/authentication_methods.rb
+++ b/lib/authentication_methods.rb
@@ -71,7 +71,7 @@ module AuthenticationMethods
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
end
end
- @authenticated_with_jwt = true
+ @authenticated_with_jwt = @authenticated_with_inst_access_token = true
end
def load_pseudonym_from_jwt
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index 065b98c50dc..ba6c699a0e1 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -25,6 +25,10 @@ describe GraphQLController do
student_in_course
end
+ let(:federation_query) do
+ 'query FED { _entities(representations: [ {__typename: Course, id: "1"} ]) { ...on Course { name } } }'
+ end
+
context "graphiql" do
it "requires a user" do
get :graphiql
@@ -68,6 +72,11 @@ describe GraphQLController do
expect(JSON.parse(response.body)["errors"]).not_to be_blank
end
+ it "does not handle Apollo Federation queries" do
+ post :execute, params: {query: federation_query}
+ expect(JSON.parse(response.body)["errors"]).not_to be_blank
+ end
+
context "data dog metrics" do
it "reports data dog metrics if requested" do
expect(InstStatsd::Statsd).to receive(:increment).with("graphql.ASDF.count", tags: anything)
@@ -77,7 +86,21 @@ describe GraphQLController do
end
end
- context "with release flag require_execute_auth disabled" do
+ describe "subgraph_execute" do
+ before { user_session(@student) }
+
+ it "handles standard queries" do
+ post :subgraph_execute, params: {query: 'query ASDF { course(id: "1") { id } }'}
+ expect(JSON.parse(response.body)["errors"]).to be_blank
+ end
+
+ it "handles Apollo Federation queries" do
+ post :subgraph_execute, params: {query: federation_query}
+ expect(JSON.parse(response.body)["errors"]).to be_blank
+ end
+ end
+
+ context "with feature flag disable_graphql_authentication enabled" do
context "graphql, without a session" do
it "works" do
diff --git a/spec/graphql/canvas_schema_spec.rb b/spec/graphql/canvas_schema_spec.rb
new file mode 100644
index 00000000000..c9c0250f79a
--- /dev/null
+++ b/spec/graphql/canvas_schema_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2017 - present Instructure, Inc.
+#
+# This file is part of Canvas.
+#
+# Canvas is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, version 3 of the License.
+#
+# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see .
+#
+
+require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
+require_relative "./graphql_spec_helper"
+
+describe CanvasSchema do
+ before(:once) { course_with_student(active_all: true, course_name: course_name) }
+
+ let(:course_name) { "Kiteboarding 101" }
+ let(:entities_query) do
+ <<~GQL
+ query EntitiesQuery($representations: [_Any!]!) {
+ _entities(representations: $representations) {
+ ... on Course {
+ name
+ }
+ }
+ }
+ GQL
+ end
+ let(:variables) { {representations: [{ __typename: 'Course', id: @course.id.to_s }]} }
+ let(:gql_context) { {current_user: @student} }
+
+ it "does not expose Apollo Federation special types" do
+ result = CanvasSchema.execute(entities_query, variables: variables, context: gql_context)
+ error_messages = result["errors"].map { |e| e["message"] }
+ expect(error_messages).to include("Field '_entities' doesn't exist on type 'Query'")
+ expect(result["data"]).to be_nil
+ end
+
+ describe ".for_federation" do
+ it "exposes Apollo Federation special types" do
+ result = CanvasSchema.for_federation.execute(entities_query, variables: variables, context: gql_context)
+ expect(result["data"]).to eq({"_entities" => [{"name" => course_name}]})
+ end
+ end
+end