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