expose federatable graphql data subgraph

fixes INTEROP-6931

most of these changes are based on the guide at
https://github.com/Gusto/apollo-federation-ruby#getting-started

there should be no functional difference to the existing graphql API
endpoint.

test plan:
 - regression smoke test on the `/api/graphql` endpoint
 - testing the new `/api/graphql/subgraph` endpoint would involve
   spinning up an Apollo Gateway server and registering that subgraph
   with it, then ensuring you can issue queries, especially ones that
   involve extension of the "Course" entity.

Change-Id: Ib4266941d28c5a8dc7c279a2909257d0a330fa7a
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/270041
Reviewed-by: Ethan Vizitei <evizitei@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Michael Ziwisky <mziwisky@instructure.com>
Product-Review: Michael Ziwisky <mziwisky@instructure.com>
This commit is contained in:
Michael Ziwisky 2021-07-09 00:40:31 -07:00
parent 338a3ef948
commit 34e0d02535
18 changed files with 234 additions and 19 deletions

View File

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

View File

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

44
app/graphql/README.md Normal file
View File

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

View File

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

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module Interfaces::AssetStringInterface
include GraphQL::Schema::Interface
include Interfaces::BaseInterface
graphql_name "AssetString"

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module Interfaces::LegacyIDInterface
include GraphQL::Schema::Interface
include Interfaces::BaseInterface
field :_id, ID, "legacy canvas id", method: :id, null: false
end

View File

@ -17,7 +17,8 @@
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

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

View File

@ -20,7 +20,7 @@
module Interfaces
module TimestampInterface
include GraphQL::Schema::Interface
include Interfaces::BaseInterface
graphql_name "Timestamped"

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
class Types::BaseField < GraphQL::Schema::Field
include ApolloFederation::Field
end

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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