Skip to content

Commit

Permalink
Merge pull request #1723 from rmosolgo/list-scoping
Browse files Browse the repository at this point in the history
Add .scope_items for filtering lists
  • Loading branch information
Robert Mosolgo authored Aug 3, 2018
2 parents 9c07ebd + fafaffb commit 5adb2eb
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 21 deletions.
45 changes: 45 additions & 0 deletions guides/authorization/scoping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
layout: guide
search: true
section: Authorization
title: Scoping
desc: Filter lists to match the current viewer and context
index: 4
---


_Scoping_ is a complementary consideration to authorization. Rather than checking "can this user see this thing?", scoping takes a list of items filters it to the subset which is appropriate for the current viewer and context. The resulting subset is authorized as normal, and, assuming that it was properly scoped, each item should pass authorization checks.

For similar features, see [Pundit scopes](https://github.com/varvet/pundit#scopes) and [Cancan's `.accessible_by`](https://github.com/cancancommunity/cancancan/wiki/Fetching-Records).

## `scope:` option

Fields accept a `scope:` option to enable (or disable) scoping, for example:

```ruby
field :products, [Types::Product], scope: true
# Or
field :all_products, [Types::Product], scope: false
```

For __list__ and __connection__ fields, `scope: true` is the default. For all other fields, `scope: false` is the default. You can override this by using the `scope:` option.

## `.scope_items(items, ctx)` method

Type classes may implement `.scope_items(items, ctx)`. This method is called when a field has `scope: true`. For example,

```ruby
field :products, [Types::Product] # has `scope: true` by default
```

Will call:

```ruby
class Types::Product < Types::BaseObject
def self.scope_items(items, context)
# filter items here
end
end
```

The method should return a new list with only the appropriate items for the current `context`.
63 changes: 46 additions & 17 deletions lib/graphql/schema/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ def self.from_options(name = nil, type = nil, desc = nil, resolver: nil, mutatio
new(**kwargs, &block)
end

# Can be set with `connection: true|false` or inferred from a type name ending in `*Connection`
# @return [Boolean] if true, this field will be wrapped with Relay connection behavior
def connection?
if @connection.nil?
# Provide default based on type name
return_type_name = if (contains_type = @field || @function)
Member::BuildType.to_type_name(contains_type.type)
elsif @return_type_expr
Member::BuildType.to_type_name(@return_type_expr)
else
raise "No connection info possible"
end
@connection = return_type_name.end_with?("Connection")
else
@connection
end
end

# @return [Boolean] if true, the return type's `.scope_items` method will be applied to this field's return value
def scoped?
if !@scope.nil?
# The default was overridden
@scope
else
@return_type_expr && type.unwrap.respond_to?(:scope_items) && (connection? || type.list?)
end
end

# @param name [Symbol] The underscore-cased version of this field name (will be camelized for the GraphQL API)
# @param return_type_expr [Class, GraphQL::BaseType, Array] The return type of this field
# @param desc [String] Field description
Expand All @@ -96,8 +124,9 @@ def self.from_options(name = nil, type = nil, desc = nil, resolver: nil, mutatio
# @param arguments [{String=>GraphQL::Schema::Argument, Hash}] Arguments for this field (may be added in the block, also)
# @param camelize [Boolean] If true, the field name will be camelized when building the schema
# @param complexity [Numeric] When provided, set the complexity for this field
# @param scope [Boolean] If true, the return type's `.scope_items` method will be called on the return value
# @param subscription_scope [Symbol, String] A key in `context` which will be used to scope subscription payloads
def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, connection: nil, max_page_size: nil, resolve: nil, introspection: false, hash_key: nil, camelize: true, complexity: 1, extras: [], resolver_class: nil, subscription_scope: nil, arguments: {}, &definition_block)
def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, connection: nil, max_page_size: nil, scope: nil, resolve: nil, introspection: false, hash_key: nil, camelize: true, complexity: 1, extras: [], resolver_class: nil, subscription_scope: nil, arguments: {}, &definition_block)

if name.nil?
raise ArgumentError, "missing first `name` argument or keyword `name:`"
Expand Down Expand Up @@ -140,6 +169,7 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function
@introspection = introspection
@extras = extras
@resolver_class = resolver_class
@scope = scope

# Override the default from HasArguments
@own_arguments = {}
Expand Down Expand Up @@ -211,18 +241,6 @@ def to_graphql
field_defn.type = -> { type }
end

if @connection.nil?
# Provide default based on type name
return_type_name = if @field || @function
Member::BuildType.to_type_name(field_defn.type)
elsif @return_type_expr
Member::BuildType.to_type_name(@return_type_expr)
else
raise "No connection info possible"
end
@connection = return_type_name.end_with?("Connection")
end

if @description
field_defn.description = @description
end
Expand All @@ -239,14 +257,14 @@ def to_graphql
end

field_defn.resolve = self.method(:resolve_field)
field_defn.connection = @connection
field_defn.connection = connection?
field_defn.connection_max_page_size = @max_page_size
field_defn.introspection = @introspection
field_defn.complexity = @complexity
field_defn.subscription_scope = @subscription_scope

# apply this first, so it can be overriden below
if @connection
if connection?
# TODO: this could be a bit weird, because these fields won't be present
# after initialization, only in the `to_graphql` response.
# This calculation _could_ be moved up if need be.
Expand Down Expand Up @@ -316,7 +334,7 @@ def resolve_field(obj, args, ctx)
inner_obj = after_obj && after_obj.object
if authorized?(inner_obj, query_ctx) && arguments.each_value.all? { |a| a.authorized?(inner_obj, query_ctx) }
# Then if it passed, resolve the field
if @resolve_proc
v = if @resolve_proc
# Might be nil, still want to call the func in that case
@resolve_proc.call(inner_obj, args, ctx)
elsif @resolver_class
Expand All @@ -325,6 +343,7 @@ def resolve_field(obj, args, ctx)
else
public_send_field(after_obj, args, ctx)
end
apply_scope(v, ctx)
else
nil
end
Expand Down Expand Up @@ -370,6 +389,16 @@ def resolve_field_method(obj, ruby_kwargs, ctx)

private

def apply_scope(value, ctx)
if scoped?
ctx.schema.after_lazy(value) do |inner_value|
@type.unwrap.scope_items(inner_value, ctx)
end
else
value
end
end

NO_ARGS = {}.freeze

def public_send_field(obj, graphql_args, field_ctx)
Expand All @@ -384,7 +413,7 @@ def public_send_field(obj, graphql_args, field_ctx)
end
end

if @connection
if connection?
# Remove pagination args before passing it to a user method
ruby_kwargs.delete(:first)
ruby_kwargs.delete(:last)
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schema/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module DefinitionMethods
include GraphQL::Schema::Member::TypeSystemHelpers
include GraphQL::Schema::Member::HasFields
include GraphQL::Schema::Member::RelayShortcuts
include GraphQL::Schema::Member::Scoped

# Methods defined in this block will be:
# - Added as class methods to this interface
Expand Down
10 changes: 6 additions & 4 deletions lib/graphql/schema/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@
require 'graphql/schema/member/cached_graphql_definition'
require 'graphql/schema/member/graphql_type_names'
require 'graphql/schema/member/relay_shortcuts'
require 'graphql/schema/member/scoped'
require 'graphql/schema/member/type_system_helpers'
require "graphql/relay/type_extensions"

module GraphQL
# The base class for things that make up the schema,
# eg objects, enums, scalars.
#
# @api private
class Schema
# The base class for things that make up the schema,
# eg objects, enums, scalars.
#
# @api private
class Member
include GraphQLTypeNames
extend CachedGraphQLDefinition
extend GraphQL::Relay::TypeExtensions
extend BaseDSLMethods
extend TypeSystemHelpers
extend Scoped
extend RelayShortcuts
end
end
Expand Down
21 changes: 21 additions & 0 deletions lib/graphql/schema/member/scoped.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module GraphQL
class Schema
class Member
module Scoped
# This is called when a field has `scope: true`.
# The field's return type class receives this call.
#
# By default, it's a no-op. Override it to scope your objects.
#
# @param items [Object] Some list-like object (eg, Array, ActiveRecord::Relation)
# @param context [GraphQL::Query::Context]
# @return [Object] Another list-like object, scoped to the current context
def scope_items(items, context)
items
end
end
end
end
end
5 changes: 5 additions & 0 deletions lib/graphql/types/relay/base_connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def edge_type(edge_type_class, edge_class: GraphQL::Relay::Edge, node_type: edge
description("The connection type for #{node_type_name}.")
end

# Filter this list according to the way its node type would scope them
def scope_items(items, context)
node_type.scope_items(items, context)
end

# Add the shortcut `nodes` field to this connection and its subclasses
def nodes_field
define_nodes_field
Expand Down
Loading

0 comments on commit 5adb2eb

Please sign in to comment.