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:
parent
338a3ef948
commit
34e0d02535
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
module Interfaces
|
||||
module TimestampInterface
|
||||
include GraphQL::Schema::Interface
|
||||
include Interfaces::BaseInterface
|
||||
|
||||
graphql_name "Timestamped"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue