sinja 0.1.0.beta1

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.
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default=>:spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'sinatra/jsonapi/resource'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require 'pry'
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+ require 'set'
3
+ require 'sinatra/base'
4
+ require 'sinatra/namespace'
5
+
6
+ require 'sinja'
7
+ require 'sinja/version'
8
+
9
+ require 'sinatra/jsonapi/config'
10
+ require 'sinatra/jsonapi/helpers/serializers'
11
+ require 'sinatra/jsonapi/resource'
12
+
13
+ module Sinatra::JSONAPI
14
+ def resource(resource_name, konst=nil, &block)
15
+ abort "Must supply proc constant or block for `resource'" \
16
+ unless block = konst and konst.is_a?(Proc) or block
17
+
18
+ sinja_config.resource_roles[resource_name.to_sym] # trigger default proc
19
+
20
+ namespace "/#{resource_name.to_s.tr('_', '-')}" do
21
+ define_singleton_method(:can) do |action, roles|
22
+ sinja_config.resource_roles[resource_name.to_sym].merge!(action=>roles)
23
+ end
24
+
25
+ helpers do
26
+ define_method(:can?) do |*args|
27
+ super(resource_name.to_sym, *args)
28
+ end
29
+ end
30
+
31
+ register Resource
32
+
33
+ instance_eval(&block)
34
+ end
35
+ end
36
+
37
+ def sinja
38
+ if block_given?
39
+ yield sinja_config
40
+ else
41
+ sinja_config
42
+ end
43
+ end
44
+
45
+ alias_method :configure_jsonapi, :sinja
46
+ def freeze_jsonapi
47
+ sinja_config.freeze
48
+ end
49
+
50
+ def self.registered(app)
51
+ app.register Sinatra::Namespace
52
+
53
+ app.disable :protection, :static
54
+ app.set :sinja_config, Sinatra::JSONAPI::Config.new
55
+ app.configure(:development) do |c|
56
+ c.set :show_exceptions, :after_handler
57
+ end
58
+
59
+ app.set :actions do |*actions|
60
+ condition do
61
+ actions.each do |action|
62
+ halt 403, 'You are not authorized to perform this action' unless can?(action)
63
+ halt 405, 'Action or method not implemented or supported' unless respond_to?(action)
64
+ end
65
+ true
66
+ end
67
+ end
68
+
69
+ app.set :nullif do |nullish|
70
+ condition { nullish.(data) }
71
+ end
72
+
73
+ app.mime_type :api_json, MIME_TYPE
74
+
75
+ app.helpers Helpers::Serializers do
76
+ def can?(resource_name, action)
77
+ roles = settings.sinja_config.resource_roles[resource_name][action]
78
+ roles.nil? || roles.empty? || Set[*role].intersect?(roles)
79
+ end
80
+
81
+ def data
82
+ @data ||= deserialized_request_body[:data]
83
+ end
84
+
85
+ def normalize_params!
86
+ # TODO: halt 400 if other params, or params not implemented?
87
+ {
88
+ :fields=>{}, # passthru
89
+ :include=>[], # passthru
90
+ :filter=>{},
91
+ :page=>{},
92
+ :sort=>''
93
+ }.each { |k, v| params[k] ||= v }
94
+ end
95
+
96
+ def role
97
+ nil
98
+ end
99
+
100
+ def transaction
101
+ yield
102
+ end
103
+ end
104
+
105
+ app.before do
106
+ halt 406 unless request.preferred_type.entry == MIME_TYPE
107
+ halt 415 unless request.media_type == MIME_TYPE
108
+ halt 415 if request.media_type_params.keys.any? { |k| k != 'charset' }
109
+
110
+ content_type :api_json
111
+
112
+ normalize_params!
113
+ end
114
+
115
+ app.after do
116
+ body serialized_response_body if response.ok?
117
+ end
118
+
119
+ app.error 400...600, nil do
120
+ serialized_error
121
+ end
122
+ end
123
+ end
124
+
125
+ module Sinatra
126
+ register JSONAPI
127
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+ require 'forwardable'
3
+ require 'set'
4
+
5
+ require 'sinatra/jsonapi/relationship_routes/has_many'
6
+ require 'sinatra/jsonapi/relationship_routes/has_one'
7
+ require 'sinatra/jsonapi/resource_routes'
8
+
9
+ module Sinatra::JSONAPI
10
+ module ConfigUtils
11
+ def deep_copy(c)
12
+ Marshal.load(Marshal.dump(c))
13
+ end
14
+
15
+ def deep_freeze(c)
16
+ c.tap { |i| i.values.each(&:freeze) }.freeze
17
+ end
18
+ end
19
+
20
+ class Config
21
+ include ConfigUtils
22
+ extend Forwardable
23
+
24
+ DEFAULT_SERIALIZER_OPTS = {
25
+ :jsonapi=>{ :version=>'1.0' }.freeze
26
+ }.freeze
27
+
28
+ DEFAULT_OPTS = {
29
+ :logger_progname=>'sinja',
30
+ :json_generator=>(Sinatra::Base.development? ? :pretty_generate : :generate),
31
+ :json_error_generator=>(Sinatra::Base.development? ? :pretty_generate : :fast_generate)
32
+ }.freeze
33
+
34
+ attr_reader \
35
+ :default_roles,
36
+ :resource_roles,
37
+ :conflict_actions,
38
+ :conflict_exceptions,
39
+ :serializer_opts
40
+
41
+ def initialize
42
+ @default_roles = RolesConfig.new
43
+ @resource_roles = Hash.new { |h, k| h[k] = @default_roles.dup }
44
+
45
+ self.conflict_actions = [
46
+ ResourceRoutes::CONFLICT_ACTIONS,
47
+ RelationshipRoutes::HasMany::CONFLICT_ACTIONS,
48
+ RelationshipRoutes::HasOne::CONFLICT_ACTIONS
49
+ ].reduce([], :concat)
50
+ self.conflict_exceptions = []
51
+
52
+ @opts = deep_copy(DEFAULT_OPTS)
53
+ self.serializer_opts = {}
54
+ end
55
+
56
+ def conflict_actions=(e=[])
57
+ @conflict_actions = Set[*e]
58
+ end
59
+
60
+ def conflict_exceptions=(e=[])
61
+ @conflict_exceptions = Set[*e]
62
+ end
63
+
64
+ def conflict?(action, exception_class)
65
+ @conflict_actions.include?(action) &&
66
+ @conflict_exceptions.include?(exception_class)
67
+ end
68
+
69
+ def_delegator :@default_roles, :merge!, :default_roles=
70
+
71
+ def serializer_opts=(h={})
72
+ @serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h)
73
+ end
74
+
75
+ DEFAULT_OPTS.keys.each do |k|
76
+ define_method(k) { @opts[k] }
77
+ define_method("#{k}=") { |v| @opts[k] = v }
78
+ end
79
+
80
+ def freeze
81
+ @default_roles.freeze
82
+ @resource_roles.default_proc = nil
83
+ deep_freeze(@resource_roles)
84
+ @conflict_actions.freeze
85
+ @conflict_exceptions.freeze
86
+ deep_freeze(@serializer_opts)
87
+ @opts.freeze
88
+ super
89
+ end
90
+ end
91
+
92
+ class RolesConfig
93
+ include ConfigUtils
94
+ extend Forwardable
95
+
96
+ def initialize
97
+ @data = [
98
+ ResourceRoutes::ACTIONS,
99
+ RelationshipRoutes::HasMany::ACTIONS,
100
+ RelationshipRoutes::HasOne::ACTIONS
101
+ ].reduce([], :concat).map { |action| [action, Set.new] }.to_h
102
+ end
103
+
104
+ def_delegator :@data, :[]
105
+
106
+ def merge!(h={})
107
+ h.each do |action, roles|
108
+ abort "Unknown or invalid action helper `#{action}' in configuration" \
109
+ unless @data.key?(action)
110
+ @data[action].replace(Set[*roles])
111
+ end
112
+ @data
113
+ end
114
+
115
+ def initialize_copy(other)
116
+ super
117
+ @data = deep_copy(other.instance_variable_get(:@data))
118
+ end
119
+
120
+ def freeze
121
+ deep_freeze(@data)
122
+ super
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module Sinatra::JSONAPI
5
+ module Helpers
6
+ module Relationships
7
+ def dispatch_relationship_request(id, path, **opts)
8
+ fake_env = env.merge 'PATH_INFO'=>"/#{id}/relationships/#{path}"
9
+ fake_env['REQUEST_METHOD'] = opts[:method].to_s.tap(&:upcase!) if opts[:method]
10
+ fake_env['rack.input'] = StringIO.new(JSON.fast_generate(opts[:body])) if opts.key?(:body)
11
+ call(fake_env) # TODO: we may need to bypass postprocessing here
12
+ end
13
+
14
+ def dispatch_relationship_requests!(id, **opts)
15
+ data.fetch(:relationships, {}).each do |path, body|
16
+ response = dispatch_relationship_request(id, path, opts.merge(:body=>body))
17
+ # TODO: Gather responses and report all errors instead of only first?
18
+ halt(*response) unless (200...300).cover?(response[0])
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ require 'sequel/model/inflections'
3
+
4
+ module Sinatra::JSONAPI
5
+ module Helpers
6
+ module Sequel
7
+ include ::Sequel::Inflections
8
+
9
+ def self.config(c)
10
+ c.conflict_exceptions = [::Sequel::ConstraintViolation]
11
+ #c.not_found_exceptions = [::Sequel::RecordNotFound]
12
+ #c.validation_exceptions = [::Sequel::ValidationVailed], proc do
13
+ # format exception to json:api source.pointer and detail
14
+ #end
15
+ end
16
+
17
+ def database
18
+ ::Sequel::DATABASES.first
19
+ end
20
+
21
+ def transaction(&block)
22
+ database.transaction(&block)
23
+ end
24
+
25
+ def next_pk(resource, **opts)
26
+ [resource.pk, resource, opts]
27
+ end
28
+
29
+ def add_missing(association, *args)
30
+ meth = "add_#{singularize(association)}".to_sym
31
+ transaction do
32
+ resource.lock!
33
+ venn(:-, association, *args) do |subresource|
34
+ resource.send(meth, subresource)
35
+ end
36
+ resource.reload
37
+ end
38
+ end
39
+
40
+ def remove_present(association, *args)
41
+ meth = "remove_#{singularize(association)}".to_sym
42
+ transaction do
43
+ resource.lock!
44
+ venn(:&, association, *args) do |subresource|
45
+ resource.send(meth, subresource)
46
+ end
47
+ resource.reload
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def venn(operator, association, rios)
54
+ klass = resource.class.association_reflection(association) # get e.g. ProductType for :types
55
+ dataset = resource.send("#{association}_dataset")
56
+ rios.map { |rio| rio[:id] }.tap(&:uniq!) # unique PKs in request payload
57
+ .send(operator, dataset.select_map(klass.primary_key)) # set operation with existing PKs in dataset
58
+ .each { |id| yield klass.with_pk!(id) } # TODO: return 404 if not found?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'jsonapi-serializers'
4
+ require 'set'
5
+
6
+ module Sinatra::JSONAPI
7
+ module Helpers
8
+ module Serializers
9
+ def deserialized_request_body
10
+ return {} unless request.body.respond_to?(:size) && request.body.size > 0
11
+
12
+ request.body.rewind
13
+ JSON.parse(request.body.read, :symbolize_names=>true)
14
+ rescue JSON::ParserError
15
+ halt 400, 'Malformed JSON in the request body'
16
+ end
17
+
18
+ def serialized_response_body
19
+ JSON.send settings.sinja_config.json_generator, response.body
20
+ rescue JSON::GeneratorError
21
+ halt 400, 'Unserializable entities in the response body'
22
+ end
23
+
24
+ def exclude!(options)
25
+ included, excluded = options.delete(:include), options.delete(:exclude)
26
+
27
+ included = Set.new(included.is_a?(Array) ? included : included.split(','))
28
+ excluded = Set.new(excluded.is_a?(Array) ? excluded : excluded.split(','))
29
+
30
+ included.delete_if do |termstr|
31
+ terms = termstr.split('.')
32
+ terms.length.times.any? do |i|
33
+ excluded.include?(terms.take(i.succ).join('.'))
34
+ end
35
+ end
36
+
37
+ options[:include] = included.to_a unless included.empty?
38
+ end
39
+
40
+ def serialize_model(model=nil, options={})
41
+ options[:is_collection] = false
42
+ options[:skip_collection_check] = defined?(::Sequel) && model.is_a?(::Sequel::Model)
43
+ options[:include] ||= params[:include] unless params[:include].empty?
44
+ options[:fields] ||= params[:fields] unless params[:fields].empty?
45
+
46
+ exclude!(options) if options[:include] && options[:exclude]
47
+
48
+ ::JSONAPI::Serializer.serialize model,
49
+ settings.sinja_config.serializer_opts.merge(options)
50
+ end
51
+
52
+ def serialize_model?(model=nil, options={})
53
+ if model
54
+ body serialize_model(model, options)
55
+ elsif options.key?(:meta)
56
+ body serialize_model(nil, :meta=>options[:meta])
57
+ else
58
+ status 204
59
+ end
60
+ end
61
+
62
+ def serialize_models(models=[], options={})
63
+ options[:is_collection] = true
64
+ options[:include] ||= params[:include] unless params[:include].empty?
65
+ options[:fields] ||= params[:fields] unless params[:fields].empty?
66
+
67
+ exclude!(options) if options[:include] && options[:exclude]
68
+
69
+ ::JSONAPI::Serializer.serialize [*models],
70
+ settings.sinja_config.serializer_opts.merge(options)
71
+ end
72
+
73
+ def serialize_models?(models=[], options={})
74
+ if [*models].any?
75
+ body serialize_models(models, options)
76
+ elsif options.key?(:meta)
77
+ body serialize_models([], :meta=>options[:meta])
78
+ else
79
+ status 204
80
+ end
81
+ end
82
+
83
+ def serialize_linkage(options={})
84
+ options = settings.sinja_config.serializer_opts.merge(options)
85
+ linkage.tap do |c|
86
+ c[:meta] = options[:meta] if options.key?(:meta)
87
+ c[:jsonapi] = options[:jsonapi] if options.key?(:jsonapi)
88
+ end
89
+ end
90
+
91
+ def serialize_linkage?(updated=false, options={})
92
+ body updated ? serialize_linkage(options) : serialize_model?(nil, options)
93
+ end
94
+
95
+ def serialize_linkages?(updated=false, options={})
96
+ body updated ? serialize_linkage(options) : serialize_models?([], options)
97
+ end
98
+
99
+ def normalized_error
100
+ return body if body.is_a?(Hash)
101
+
102
+ if not_found? && detail = [*body].first
103
+ title = 'Not Found'
104
+ detail = nil if detail == '<h1>Not Found</h1>'
105
+ elsif env.key?('sinatra.error')
106
+ title = 'Unknown Error'
107
+ detail = env['sinatra.error'].message
108
+ elsif detail = [*body].first
109
+ end
110
+
111
+ { title: title, detail: detail }
112
+ end
113
+
114
+ def error_hash(title: nil, detail: nil, source: nil)
115
+ { id: SecureRandom.uuid }.tap do |hash|
116
+ hash[:title] = title if title
117
+ hash[:detail] = detail if detail
118
+ hash[:status] = status.to_s if status
119
+ hash[:source] = source if source
120
+ end
121
+ end
122
+
123
+ def serialized_error
124
+ hash = error_hash(normalized_error)
125
+ logger.error(settings.sinja_config.logger_progname) { hash }
126
+ JSON.send settings.sinja_config.json_error_generator,
127
+ ::JSONAPI::Serializer.serialize_errors([hash])
128
+ end
129
+ end
130
+ end
131
+ end