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.
- 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
|