sinja 0.1.0.beta1

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