Skip to content

Commit

Permalink
Add cached, named Visibility profiles
Browse files Browse the repository at this point in the history
  • Loading branch information
rmosolgo committed Aug 26, 2024
1 parent e510a44 commit fcfed4c
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 13 deletions.
5 changes: 3 additions & 2 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ def selected_operation_name
# @param root_value [Object] the object used to resolve fields on the root type
# @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value)
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_schema_subset: nil)
# @param visibility_profile [Symbol]
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, visibility_profile: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_schema_subset: nil)
# Even if `variables: nil` is passed, use an empty hash for simpler logic
variables ||= {}
@schema = schema
Expand All @@ -106,7 +107,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
end

if use_schema_subset
@schema_subset = @schema.subset_class.new(context: @context, schema: @schema)
@schema_subset = @schema.visibility.profile_for(@context, visibility_profile)
@warden = Schema::Warden::NullWarden.new(context: @context, schema: @schema)
else
@schema_subset = nil
Expand Down
3 changes: 2 additions & 1 deletion lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,8 @@ def subset_class
end
end

attr_writer :subset_class, :use_schema_visibility, :visibility
attr_writer :subset_class, :use_schema_visibility
attr_accessor :visibility

def use_schema_visibility?
if defined?(@use_schema_visibility)
Expand Down
41 changes: 34 additions & 7 deletions lib/graphql/schema/visibility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,52 @@

module GraphQL
class Schema
# Use this plugin to make some parts of your schema hidden from some viewers.
#
class Visibility
def self.use(schema, preload: nil, migration_errors: false)
schema.visibility = self.new(schema, preload: preload)
# @param schema [Class<GraphQL::Schema>]
# @param profiles [Hash<Symbol => Hash>] A hash of `name => context` pairs for preloading visibility profiles
# @param preload [Boolean] if `true`, load the default schema profile and all named profiles immediately (defaults to `true` for `Rails.env.production?`)
# @param migration_errors [Boolean] if `true`, raise an error when `Visibility` and `Warden` return different results
def self.use(schema, dynamic: false, profiles: nil, preload: (defined?(Rails) ? Rails.env.production? : nil), migration_errors: false)
schema.visibility = self.new(schema, dynamic: dynamic, preload: preload, profiles: profiles,)
schema.use_schema_visibility = true
if migration_errors
schema.subset_class = Migration
end
end

def initialize(schema, preload:)
def initialize(schema, dynamic:, preload:, profiles:)
@schema = schema
@cached_subsets = {}
@profiles = profiles
@cached_profiles = {}
@dynamic = dynamic

if preload.nil? && defined?(Rails) && Rails.env.production?
preload = true
if preload
profiles.each do |profile_name, example_ctx|
prof = profile_for(example_ctx, profile_name)
prof.all_types # force loading
end
end
end

if preload
attr_reader :cached_profiles

def profile_for(context, visibility_profile)
if @profiles.any?
if visibility_profile.nil?
if @dynamic
Subset.new(context: context, schema: @schema)
elsif @profiles.any?
raise ArgumentError, "#{@schema} expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration."
end
elsif !@profiles.include?(visibility_profile)
raise ArgumentError, "`#{visibility_profile.inspect}` isn't allowed for `visibility_profile:` (must be one of #{@profiles.keys.map(&:inspect).join(", ")}). Or, add `#{visibility_profile.inspect}` to the list of profiles in the schema definition."
else
@cached_profiles[visibility_profile] ||= Subset.new(name: visibility_profile, context: context, schema: @schema)
end
else
Subset.new(context: context, schema: @schema)
end
end
end
Expand Down
10 changes: 7 additions & 3 deletions lib/graphql/schema/visibility/subset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ class Visibility
# - It checks `.visible?` on root introspection types
#
# In the future, {Subset} will support lazy-loading types as needed during execution and multi-request caching of subsets.
# TODO rename to Profile?
class Subset
# @return [Schema::Visibility::Subset]
def self.from_context(ctx, schema)
if ctx.respond_to?(:types) && (types = ctx.types).is_a?(self)
types
else
# TODO use a cached instance from the schema
self.new(context: ctx, schema: schema)
schema.visibility.profile_for(ctx, nil)
end
end

Expand All @@ -30,7 +30,11 @@ def self.pass_thru(context:, schema:)
subset
end

def initialize(context:, schema:)
# @return [Symbol, nil]
attr_reader :name

def initialize(name: nil, context:, schema:)
@name = name
@context = context
@schema = schema
@all_types = {}
Expand Down
95 changes: 95 additions & 0 deletions spec/graphql/schema/visibility_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true
require "spec_helper"

describe GraphQL::Schema::Visibility do
class VisSchema < GraphQL::Schema
class BaseField < GraphQL::Schema::Field
def initialize(*args, admin_only: false, **kwargs, &block)
super(*args, **kwargs, &block)
@admin_only = admin_only
end

def visible?(ctx)
super && if @admin_only
!!ctx[:is_admin]
else
true
end
end
end

class BaseObject < GraphQL::Schema::Object
field_class(BaseField)
end

class Product < BaseObject
field :name, String
field :price, Integer
field :cost_of_goods_sold, Integer, admin_only: true
field :quantity_in_stock, Integer
end

class Query < BaseObject
field :products, [Product]

def products
[{ name: "Pool Noodle", price: 100, cost_of_goods_sold: 5, quantity_in_stock: 100 }]
end
end

query(Query)
use GraphQL::Schema::Visibility, profiles: { public: {}, admin: { is_admin: true }, edge: {} }, preload: true
end

class DynVisSchema < VisSchema
use GraphQL::Schema::Visibility, profiles: { public: {}, admin: {}, edge: {} }, dynamic: true, preload: false
end

def exec_query(...)
VisSchema.execute(...)
end
describe "running queries" do
it "requires context[:visibility]" do
err = assert_raises ArgumentError do
exec_query("{ products { name } }")
end
expected_msg = "VisSchema expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration."
assert_equal expected_msg, err.message
end

it "requires a context[:visibility] which is on the list" do
err = assert_raises ArgumentError do
exec_query("{ products { name } }", visibility_profile: :nonsense )
end
expected_msg = "`:nonsense` isn't allowed for `visibility_profile:` (must be one of :public, :admin, :edge). Or, add `:nonsense` to the list of profiles in the schema definition."
assert_equal expected_msg, err.message
end

it "permits `nil` when nil is on the list" do
res = DynVisSchema.execute("{ products { name } }")
assert_equal 1, res["data"]["products"].size
assert_nil res.context.types.name
assert_equal [], DynVisSchema.visibility.cached_profiles.keys
end

it "uses the named visibility" do
res = exec_query("{ products { name } }", visibility_profile: :public)
assert_equal ["Pool Noodle"], res["data"]["products"].map { |p| p["name"] }
assert_equal :public, res.context.types.name
assert res.context.types.equal?(VisSchema.visibility.cached_profiles[:public]), "It uses the cached instance"

res = exec_query("{ products { costOfGoodsSold } }", visibility_profile: :public)
assert_equal ["Field 'costOfGoodsSold' doesn't exist on type 'Product'"], res["errors"].map { |e| e["message"] }

res = exec_query("{ products { name costOfGoodsSold } }", visibility_profile: :admin)
assert_equal [{ "name" => "Pool Noodle", "costOfGoodsSold" => 5}], res["data"]["products"]
end
end

describe "preloading profiles" do
it "preloads when true" do
assert_equal [:public, :admin, :edge], VisSchema.visibility.cached_profiles.keys, "preload: true"
assert_equal 0, DynVisSchema.visibility.cached_profiles.size, "preload: false"
end
end
end

0 comments on commit fcfed4c

Please sign in to comment.