sinja 0.1.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +755 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/sinatra/jsonapi.rb +127 -0
- data/lib/sinatra/jsonapi/config.rb +125 -0
- data/lib/sinatra/jsonapi/helpers/relationships.rb +23 -0
- data/lib/sinatra/jsonapi/helpers/sequel.rb +62 -0
- data/lib/sinatra/jsonapi/helpers/serializers.rb +131 -0
- data/lib/sinatra/jsonapi/relationship_routes/has_many.rb +35 -0
- data/lib/sinatra/jsonapi/relationship_routes/has_one.rb +25 -0
- data/lib/sinatra/jsonapi/resource.rb +137 -0
- data/lib/sinatra/jsonapi/resource_routes.rb +63 -0
- data/lib/sinja.rb +13 -0
- data/lib/sinja/version.rb +6 -0
- data/sinja.gemspec +30 -0
- metadata +163 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -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
|
data/bin/setup
ADDED
@@ -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
|