graphiti 1.0.alpha.1 → 1.0.alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -8
  3. data/README.md +2 -72
  4. data/bin/console +1 -1
  5. data/exe/graphiti +5 -0
  6. data/graphiti.gemspec +2 -2
  7. data/lib/generators/jsonapi/resource_generator.rb +1 -1
  8. data/lib/generators/jsonapi/templates/application_resource.rb.erb +0 -2
  9. data/lib/graphiti.rb +17 -1
  10. data/lib/graphiti/adapters/abstract.rb +32 -0
  11. data/lib/graphiti/adapters/active_record/base.rb +8 -0
  12. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +2 -9
  13. data/lib/graphiti/cli.rb +45 -0
  14. data/lib/graphiti/configuration.rb +12 -0
  15. data/lib/graphiti/context.rb +1 -0
  16. data/lib/graphiti/errors.rb +105 -3
  17. data/lib/graphiti/filter_operators.rb +13 -3
  18. data/lib/graphiti/query.rb +8 -0
  19. data/lib/graphiti/rails.rb +1 -1
  20. data/lib/graphiti/railtie.rb +21 -2
  21. data/lib/graphiti/renderer.rb +2 -1
  22. data/lib/graphiti/resource.rb +11 -2
  23. data/lib/graphiti/resource/configuration.rb +1 -0
  24. data/lib/graphiti/resource/dsl.rb +4 -3
  25. data/lib/graphiti/resource/interface.rb +15 -0
  26. data/lib/graphiti/resource/links.rb +92 -0
  27. data/lib/graphiti/resource/polymorphism.rb +1 -0
  28. data/lib/graphiti/resource/sideloading.rb +13 -5
  29. data/lib/graphiti/resource_proxy.rb +1 -1
  30. data/lib/graphiti/schema.rb +169 -0
  31. data/lib/graphiti/schema_diff.rb +174 -0
  32. data/lib/graphiti/scoping/filter.rb +1 -1
  33. data/lib/graphiti/sideload.rb +47 -18
  34. data/lib/graphiti/sideload/belongs_to.rb +5 -1
  35. data/lib/graphiti/sideload/has_many.rb +5 -1
  36. data/lib/graphiti/sideload/many_to_many.rb +6 -2
  37. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +3 -1
  38. data/lib/graphiti/types.rb +39 -17
  39. data/lib/graphiti/util/class.rb +22 -0
  40. data/lib/graphiti/util/hash.rb +16 -0
  41. data/lib/graphiti/util/hooks.rb +2 -2
  42. data/lib/graphiti/util/link.rb +48 -0
  43. data/lib/graphiti/util/serializer_relationships.rb +94 -0
  44. data/lib/graphiti/version.rb +1 -1
  45. metadata +16 -7
@@ -3,8 +3,18 @@ module Graphiti
3
3
  class Catchall
4
4
  attr_reader :procs
5
5
 
6
- def initialize
6
+ def initialize(resource, type_name, opts)
7
7
  @procs = {}
8
+ defaults = resource.adapter.default_operators[type_name] || []
9
+ if opts[:only]
10
+ defaults = defaults.select { |op| opts[:only].include?(op) }
11
+ end
12
+ if opts[:except]
13
+ defaults = defaults.reject { |op| opts[:except].include?(op) }
14
+ end
15
+ defaults.each do |op|
16
+ @procs[op] = nil
17
+ end
8
18
  end
9
19
 
10
20
  def method_missing(name, *args, &blk)
@@ -16,8 +26,8 @@ module Graphiti
16
26
  end
17
27
  end
18
28
 
19
- def self.build(&blk)
20
- c = Catchall.new
29
+ def self.build(resource, type_name, opts = {}, &blk)
30
+ c = Catchall.new(resource, type_name, opts)
21
31
  c.instance_eval(&blk) if blk
22
32
  c.to_hash
23
33
  end
@@ -21,6 +21,14 @@ module Graphiti
21
21
  not association?
22
22
  end
23
23
 
24
+ def links?
25
+ if Graphiti.config.links_on_demand
26
+ [true, 'true'].include?(@params[:links])
27
+ else
28
+ true
29
+ end
30
+ end
31
+
24
32
  def to_hash
25
33
  {}.tap do |hash|
26
34
  hash[:filter] = filters unless filters.empty?
@@ -10,7 +10,7 @@ module Graphiti
10
10
  def self.included(klass)
11
11
  klass.class_eval do
12
12
  include Graphiti::Context
13
- include JsonapiErrorable
13
+ include GraphitiErrors
14
14
  around_action :wrap_context
15
15
  end
16
16
  end
@@ -1,6 +1,5 @@
1
1
  module Graphiti
2
2
  class Railtie < ::Rails::Railtie
3
-
4
3
  initializer "graphiti.require_activerecord_adapter" do
5
4
  config.after_initialize do |app|
6
5
  ActiveSupport.on_load(:active_record) do
@@ -16,6 +15,7 @@ module Graphiti
16
15
  register_parameter_parser
17
16
  register_renderers
18
17
  establish_concurrency
18
+ configure_endpoint_lookup
19
19
  end
20
20
 
21
21
  # from jsonapi-rails
@@ -55,7 +55,7 @@ module Graphiti
55
55
  ::ActionController::Renderers.add(:jsonapi_errors) do |proxy, options|
56
56
  self.content_type ||= Mime[:jsonapi]
57
57
 
58
- validation = JsonapiErrorable::Serializers::Validation.new \
58
+ validation = GraphitiErrors::Serializers::Validation.new \
59
59
  proxy.data, proxy.payload.relationships
60
60
 
61
61
  render \
@@ -70,5 +70,24 @@ module Graphiti
70
70
  Graphiti.config.concurrency = !::Rails.env.test? &&
71
71
  ::Rails.application.config.cache_classes
72
72
  end
73
+
74
+ def configure_endpoint_lookup
75
+ Graphiti.config.context_for_endpoint = ->(path, action) {
76
+ method = :GET
77
+ case action
78
+ when :show then path = "#{path}/1"
79
+ when :create then method = :POST
80
+ when :update
81
+ path = "#{path}/1"
82
+ method = :PUT
83
+ when :destroy
84
+ path = "#{path}/1"
85
+ method = :DELETE
86
+ end
87
+
88
+ route = ::Rails.application.routes.recognize_path(path, method: method) rescue nil
89
+ "#{route[:controller]}_controller".classify.safe_constantize if route
90
+ }
91
+ end
73
92
  end
74
93
  end
@@ -33,6 +33,7 @@ module Graphiti
33
33
  options[:fields] = proxy.fields
34
34
  options[:expose] ||= {}
35
35
  options[:expose][:extra_fields] = proxy.extra_fields
36
+ options[:expose][:proxy] = proxy
36
37
  options[:include] = proxy.include_hash
37
38
  options[:meta] ||= {}
38
39
  options[:meta].merge!(stats: proxy.stats) unless proxy.stats.empty?
@@ -45,7 +46,7 @@ module Graphiti
45
46
  def notify
46
47
  if defined?(ActiveSupport::Notifications)
47
48
  opts = [
48
- 'render.jsonapi-compliable',
49
+ 'render.graphiti',
49
50
  records: records,
50
51
  options: options
51
52
  ]
@@ -4,6 +4,7 @@ module Graphiti
4
4
  include Interface
5
5
  include Configuration
6
6
  include Sideloading
7
+ include Links
7
8
 
8
9
  attr_reader :context
9
10
 
@@ -21,14 +22,22 @@ module Graphiti
21
22
  end
22
23
  end
23
24
 
24
- def context
25
+ def self.context
25
26
  Graphiti.context[:object]
26
27
  end
27
28
 
28
- def context_namespace
29
+ def context
30
+ self.class.context
31
+ end
32
+
33
+ def self.context_namespace
29
34
  Graphiti.context[:namespace]
30
35
  end
31
36
 
37
+ def context_namespace
38
+ self.class.context_namespace
39
+ end
40
+
32
41
  def build_scope(base, query, opts = {})
33
42
  Scope.new(base, self, query, opts)
34
43
  end
@@ -87,6 +87,7 @@ module Graphiti
87
87
  klass.attribute :id, :integer_id
88
88
  end
89
89
  klass.stat total: [:count]
90
+ Graphiti.resources << klass
90
91
  end
91
92
  end
92
93
 
@@ -9,11 +9,12 @@ module Graphiti
9
9
 
10
10
  if att = get_attr(name, :filterable, raise_error: :only_unsupported)
11
11
  aliases = [name, opts[:aliases]].flatten.compact
12
- operators = FilterOperators.build(&blk)
12
+ operators = FilterOperators.build(self, att[:type], opts, &blk)
13
13
  config[:filters][name.to_sym] = {
14
14
  aliases: aliases,
15
- type: att[:type]
16
- }.merge(operators.to_hash)
15
+ type: att[:type],
16
+ operators: operators.to_hash
17
+ }
17
18
  else
18
19
  if type = args[0]
19
20
  attribute name, type, only: [:filterable]
@@ -5,15 +5,18 @@ module Graphiti
5
5
 
6
6
  class_methods do
7
7
  def all(params = {}, base_scope = nil)
8
+ validate!
8
9
  _all(params, {}, base_scope)
9
10
  end
10
11
 
12
+ # @api private
11
13
  def _all(params, opts, base_scope)
12
14
  runner = Runner.new(self, params)
13
15
  runner.proxy(base_scope, opts)
14
16
  end
15
17
 
16
18
  def find(params, base_scope = nil)
19
+ validate!
17
20
  id = params[:data].try(:[], :id) || params.delete(:id)
18
21
  params[:filter] ||= {}
19
22
  params[:filter].merge!(id: id)
@@ -23,9 +26,21 @@ module Graphiti
23
26
  end
24
27
 
25
28
  def build(params, base_scope = nil)
29
+ validate!
26
30
  runner = Runner.new(self, params)
27
31
  runner.proxy(base_scope, single: true, raise_on_missing: true)
28
32
  end
33
+
34
+ private
35
+
36
+ def validate!
37
+ if context && context.respond_to?(:request)
38
+ path = context.request.env['PATH_INFO']
39
+ unless allow_request?(path, context_namespace)
40
+ raise Errors::InvalidEndpoint.new(self, path, context_namespace)
41
+ end
42
+ end
43
+ end
29
44
  end
30
45
  end
31
46
  end
@@ -0,0 +1,92 @@
1
+ module Graphiti
2
+ module Links
3
+ extend ActiveSupport::Concern
4
+
5
+ DEFAULT_ACTIONS = [:index, :show, :create, :update, :destroy].freeze
6
+
7
+ module Overrides
8
+ def endpoint
9
+ if endpoint = super
10
+ endpoint
11
+ else
12
+ self.endpoint = infer_endpoint
13
+ end
14
+ end
15
+ end
16
+
17
+ included do
18
+ class_attribute :endpoint,
19
+ :base_url,
20
+ :endpoint_namespace,
21
+ :secondary_endpoints,
22
+ :autolink
23
+ self.secondary_endpoints = []
24
+ self.autolink = true
25
+
26
+ class << self
27
+ prepend Overrides
28
+ end
29
+ end
30
+
31
+ class_methods do
32
+ def infer_endpoint
33
+ return unless name
34
+
35
+ path = "/#{name.gsub('Resource', '').pluralize.underscore}".to_sym
36
+ {
37
+ path: path,
38
+ full_path: full_path_for(path),
39
+ url: url_for(path),
40
+ actions: DEFAULT_ACTIONS.dup
41
+ }
42
+ end
43
+
44
+ def primary_endpoint(path, actions = DEFAULT_ACTIONS.dup)
45
+ path = path.to_sym
46
+ self.endpoint = {
47
+ path: path,
48
+ full_path: full_path_for(path),
49
+ url: url_for(path),
50
+ actions: actions
51
+ }
52
+ end
53
+
54
+ # NB: avoid << b/c class_attribute
55
+ def secondary_endpoint(path, actions = DEFAULT_ACTIONS.dup)
56
+ path = path.to_sym
57
+ self.secondary_endpoints += [{
58
+ path: path,
59
+ full_path: full_path_for(path),
60
+ url: url_for(path),
61
+ actions: actions
62
+ }]
63
+ end
64
+
65
+ def endpoints
66
+ ([endpoint] + secondary_endpoints).compact
67
+ end
68
+
69
+ def allow_request?(path, action)
70
+ endpoints.any? do |e|
71
+ if [:update, :show, :destroy].include?(context_namespace)
72
+ path = path.split('/')
73
+ path.pop
74
+ path = path.join('/')
75
+ end
76
+
77
+ e[:full_path].to_s == path && e[:actions].include?(context_namespace)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def full_path_for(path)
84
+ [endpoint_namespace, path].join('').to_sym
85
+ end
86
+
87
+ def url_for(path)
88
+ [base_url, full_path_for(path)].join('').to_sym
89
+ end
90
+ end
91
+ end
92
+ end
@@ -34,6 +34,7 @@ module Graphiti
34
34
  def inherited(klass)
35
35
  klass.type = nil
36
36
  klass.model = klass.infer_model
37
+ klass.endpoint = klass.infer_endpoint
37
38
  klass.polymorphic_child = true
38
39
  super
39
40
  end
@@ -23,11 +23,7 @@ module Graphiti
23
23
  end
24
24
 
25
25
  def apply_sideloads_to_serializer
26
- config[:sideloads].each_pair do |name, sideload|
27
- if serializer.relationship_blocks[name].nil? && sideload.readable?
28
- serializer.relationship(name)
29
- end
30
- end
26
+ Util::SerializerRelationships.new(self, config[:sideloads]).apply
31
27
  end
32
28
 
33
29
  def has_many(name, opts = {}, &blk)
@@ -61,6 +57,18 @@ module Graphiti
61
57
  allow_sideload(name, opts, &blk)
62
58
  end
63
59
 
60
+ def belongs_to_many(name, resource: nil, as:, &blk)
61
+ resource = resource ||= Util::Class.infer_resource_class(self, name)
62
+ sideload = resource.sideload(as)
63
+
64
+ _adapter = adapter
65
+ filter sideload.true_foreign_key, resource.attributes[:id][:type] do
66
+ eq do |scope, value|
67
+ _adapter.belongs_to_many_filter(sideload, scope, value)
68
+ end
69
+ end
70
+ end
71
+
64
72
  def sideload(name)
65
73
  sideloads[name]
66
74
  end
@@ -2,7 +2,7 @@ module Graphiti
2
2
  class ResourceProxy
3
3
  include Enumerable
4
4
 
5
- attr_reader :resource, :query, :scope
5
+ attr_reader :resource, :query, :scope, :payload
6
6
 
7
7
  def initialize(resource, scope, query, payload: nil, single: false, raise_on_missing: false)
8
8
  @resource = resource
@@ -0,0 +1,169 @@
1
+ module Graphiti
2
+ class Schema
3
+ attr_reader :resources
4
+
5
+ def self.generate(resources = nil)
6
+ ::Rails.application.eager_load! if defined?(::Rails)
7
+ resources ||= Graphiti.resources.reject(&:abstract_class?)
8
+ new(resources).generate
9
+ end
10
+
11
+ def self.generate!(resources = nil)
12
+ schema = generate(resources)
13
+
14
+ if ENV['FORCE_SCHEMA'] != 'true' && File.exists?(Graphiti.config.schema_path)
15
+ old = JSON.parse(File.read(Graphiti.config.schema_path))
16
+ errors = Graphiti::SchemaDiff.new(old, schema).compare
17
+ return errors if errors.any?
18
+ end
19
+ File.write(Graphiti.config.schema_path, JSON.pretty_generate(schema))
20
+ []
21
+ end
22
+
23
+ def initialize(resources)
24
+ @resources = resources
25
+ end
26
+
27
+ def generate
28
+ {
29
+ resources: generate_resources,
30
+ endpoints: generate_endpoints,
31
+ types: generate_types
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def generate_types
38
+ {}.tap do |types|
39
+ Graphiti::Types.map.each_pair do |name, config|
40
+ types[name] = config.slice(:kind, :description)
41
+ end
42
+ end
43
+ end
44
+
45
+ def generate_endpoints
46
+ {}.tap do |endpoints|
47
+ @resources.each do |r|
48
+ r.endpoints.each do |e|
49
+ actions = {}
50
+ e[:actions].each do |a|
51
+ next unless ctx = context_for(e[:full_path], a)
52
+
53
+ existing = endpoints[e[:full_path]]
54
+ if existing && config = existing[:actions][a]
55
+ raise Errors::ResourceEndpointConflict.new \
56
+ e[:full_path], a, r.name, config[:resource]
57
+ end
58
+
59
+ actions[a] = { resource: r.name }
60
+ if whitelist = ctx.sideload_whitelist
61
+ if whitelist[a]
62
+ actions[a].merge!(sideload_whitelist: whitelist[a])
63
+ end
64
+ end
65
+ end
66
+
67
+ unless actions.empty?
68
+ endpoints[e[:full_path]] ||= { actions: {} }
69
+ endpoints[e[:full_path]][:actions].merge!(actions)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def context_for(path, action)
77
+ Graphiti.config.context_for_endpoint.call(path.to_s, action)
78
+ end
79
+
80
+ def generate_resources
81
+ @resources.map do |r|
82
+ config = {
83
+ name: r.name,
84
+ type: r.type.to_s,
85
+ attributes: attributes(r),
86
+ extra_attributes: extra_attributes(r),
87
+ filters: filters(r),
88
+ relationships: relationships(r)
89
+ }
90
+
91
+ if r.polymorphic?
92
+ config.merge!(polymorphic: true, children: r.children.map(&:name))
93
+ end
94
+
95
+ config
96
+ end
97
+ end
98
+
99
+ def attributes(resource)
100
+ {}.tap do |attrs|
101
+ resource.attributes.each_pair do |name, config|
102
+ if config.values_at(:readable, :writable, :sortable).any?
103
+ attrs[name] = {
104
+ type: config[:type].to_s,
105
+ readable: flag(config[:readable]),
106
+ writable: flag(config[:writable]),
107
+ sortable: flag(config[:sortable])
108
+ }
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ def extra_attributes(resource)
115
+ {}.tap do |attrs|
116
+ resource.extra_attributes.each_pair do |name, config|
117
+ attrs[name] = {
118
+ type: config[:type].to_s,
119
+ readable: flag(config[:readable])
120
+ }
121
+ end
122
+ end
123
+ end
124
+
125
+ def flag(value)value
126
+ if value.is_a?(Symbol)
127
+ 'guarded'
128
+ else
129
+ !!value
130
+ end
131
+ end
132
+
133
+ def filters(resource)
134
+ {}.tap do |f|
135
+ resource.filters.each_pair do |name, config|
136
+ config = {
137
+ type: config[:type].to_s,
138
+ operators: config[:operators].keys.map(&:to_s)
139
+ }
140
+ attr = resource.attributes[name]
141
+ if attr[:filterable].is_a?(Symbol)
142
+ if attr[:filterable] == :required
143
+ config[:required] = true
144
+ else
145
+ config[:guard] = true
146
+ end
147
+ end
148
+ f[name] = config
149
+ end
150
+ end
151
+ end
152
+
153
+ def relationships(resource)
154
+ {}.tap do |r|
155
+ resource.sideloads.each_pair do |name, config|
156
+ schema = { type: config.type.to_s }
157
+ if config.type == :polymorphic_belongs_to
158
+ schema[:resources] = config.children.values
159
+ .map(&:resource).map(&:class).map(&:name)
160
+ else
161
+ schema[:resource] = config.resource.class.name
162
+ end
163
+
164
+ r[name] = schema
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end