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

Sign up to get free protection for your applications and to get access to all the features.
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