sinja 1.0.0.pre2 → 1.1.0.pre1

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,58 @@
1
+ ## Demo App
2
+
3
+ This is the demo app for Sinja, used as an example of and for testing Sinja. It
4
+ is a very simplistic blog-like application with database tables, models,
5
+ serializers, and controllers for authors, posts, comments, and tags. It uses
6
+ [Sequel ORM](http://sequel.jeremyevans.net) (and the [Sequel
7
+ helpers](/lib/sinja/helpers/sequel.rb) provided with Sinja) with an in-memory
8
+ SQLite database, and it works under both MRI/YARV 2.3+ and JRuby 9.1+.
9
+
10
+ ### Usage
11
+
12
+ Assuming you have a working, Bundler-enabled Ruby environment, simply clone
13
+ this repo, `cd` into the `demo-app` subdirectory, and run the following
14
+ commands:
15
+
16
+ ```
17
+ $ bundle install
18
+ $ bundle exec ruby app.rb [-p <PORT>]
19
+ ```
20
+
21
+ The web server will report the port it's listening on, or you can specify a
22
+ port with the `-p` option. It will respond to {json:api}-compliant requests
23
+ (don't forget to set an `Accept` header) to `/authors`, `/posts`, `/comments`,
24
+ and `/tags`, although not every endpoint is implemented. Log in by setting the
25
+ `X-Email` header on the request to the email address of a registered user; the
26
+ default administrator email address is all@yourbase.com.
27
+
28
+ **This is clearly extremely insecure and should not be used as-is in production.
29
+ Caveat emptor.**
30
+
31
+ You can point it at a different database by setting `DATABASE_URL` in the
32
+ environment before executing `app.rb`. See the relevant [Sequel
33
+ documentation](http://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html)
34
+ for more information. It (rather na&iuml;vely) migrates the database at
35
+ startup.
36
+
37
+ ### Productionalizing
38
+
39
+ You can certainly use this as a starting point for a production application,
40
+ but you will at least want to:
41
+
42
+ - [ ] Use a persistent database
43
+ - [ ] Separate the class files (e.g. `author.rb`, `post.rb`) into separate
44
+ files for the migrations, models, serializers, and Sinja controllers
45
+ - [ ] Create a Gemfile using the dependencies in the top-level
46
+ [gemspec](/sinja.gemspec) as a starting point
47
+ - [ ] Add authentication middleware and rewrite the `role` helper to enable
48
+ the authorization scheme. You can use the existing roles as defined or
49
+ rename them (e.g. use `:admin` instead of `:superuser`)
50
+ - [ ] Use a real application server such as [Puma](http://puma.io) or
51
+ [Passenger](https://www.phusionpassenger.com) instead of Ruby's
52
+ stdlib (WEBrick)
53
+ - [ ] Configure Sequel's connection pool (i.e. `:max_connections`) to match the
54
+ application server's thread pool (if any)
55
+ - [ ] Add caching directives (i.e. `cache_control`, `expires`, `last_modified`,
56
+ and `etag`) as appropriate
57
+
58
+ And probably a whole lot more!
data/demo-app/app.rb CHANGED
@@ -9,6 +9,10 @@ require_relative 'classes/tag'
9
9
 
10
10
  require 'sinja/helpers/sequel'
11
11
 
12
+ configure :development do
13
+ set :server_settings, AccessLog: [] # avoid WEBrick double-logging issue
14
+ end
15
+
12
16
  configure_jsonapi do |c|
13
17
  Sinja::Helpers::Sequel.config(c)
14
18
  end
@@ -19,16 +23,16 @@ helpers Sinja::Helpers::Sequel do
19
23
  Author.first_by_email(env['HTTP_X_EMAIL']) if env.key?('HTTP_X_EMAIL')
20
24
  end
21
25
 
26
+ def database
27
+ DB
28
+ end
29
+
22
30
  def role
23
31
  [].tap do |a|
24
32
  a << :logged_in if current_user
25
33
  a << :superuser if current_user&.admin?
26
34
  end
27
35
  end
28
-
29
- def database
30
- DB
31
- end
32
36
  end
33
37
 
34
38
  resource :authors, AuthorController
data/demo-app/boot.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'bundler/setup'
3
3
 
4
- Bundler.require(:default, :development)
4
+ Bundler.require :default
5
+ Bundler.require Sinatra::Base.environment
6
+ Bundler.require :development if Sinatra::Base.test?
@@ -4,10 +4,10 @@ require_relative '../database'
4
4
 
5
5
  DB.create_table?(:authors) do
6
6
  primary_key :id
7
- String :email, :null=>false, :unique=>true
7
+ String :email, null: false, unique: true
8
8
  String :real_name
9
9
  String :display_name
10
- TrueClass :admin, :default=>false
10
+ TrueClass :admin, default: false
11
11
  DateTime :created_at
12
12
  DateTime :updated_at
13
13
  end
@@ -17,13 +17,14 @@ class Author < Sequel::Model
17
17
  plugin :boolean_readers
18
18
 
19
19
  finder def self.by_email(arg)
20
- where(:email=>arg)
20
+ where(email: arg)
21
21
  end
22
22
 
23
23
  one_to_many :comments
24
24
  one_to_many :posts
25
25
  end
26
26
 
27
+ # We have to create an admin user here, otherwise we have no way to create one.
27
28
  Author.create(email: 'all@yourbase.com', admin: true)
28
29
 
29
30
  class AuthorSerializer < BaseSerializer
@@ -40,14 +41,12 @@ AuthorController = proc do
40
41
  end
41
42
 
42
43
  def role
43
- if resource == current_user
44
- super.push(:self)
45
- else
46
- super
44
+ [*super].tap do |a|
45
+ a << :myself if resource == current_user
47
46
  end
48
47
  end
49
48
 
50
- def fields
49
+ def settable_fields
51
50
  %i[email real_name display_name].tap do |a|
52
51
  a << :admin if role?(:superuser)
53
52
  end
@@ -56,33 +55,37 @@ AuthorController = proc do
56
55
 
57
56
  show
58
57
 
58
+ show_many do |ids|
59
+ Author.where(id: ids.map!(&:to_i)).all
60
+ end
61
+
59
62
  index do
60
- Author.all
63
+ Author.dataset
61
64
  end
62
65
 
63
66
  create do |attr|
64
67
  author = Author.new
65
- author.set_fields(attr, fields)
68
+ author.set_fields(attr, settable_fields)
66
69
  next_pk author.save(validate: false)
67
70
  end
68
71
 
69
- update(roles: %i[self superuser]) do |attr|
70
- resource.update_fields(attr, fields, validate: false, missing: :skip)
72
+ update(roles: %i[myself superuser]) do |attr|
73
+ resource.update_fields(attr, settable_fields, validate: false, missing: :skip)
71
74
  end
72
75
 
73
- destroy(roles: %i[self superuser]) do
76
+ destroy(roles: %i[myself superuser]) do
74
77
  resource.destroy
75
78
  end
76
79
 
77
80
  has_many :comments do
78
81
  fetch(roles: :logged_in) do
79
- resource.comments
82
+ resource.comments_dataset
80
83
  end
81
84
  end
82
85
 
83
86
  has_many :posts do
84
87
  fetch do
85
- resource.posts
88
+ resource.posts_dataset
86
89
  end
87
90
  end
88
91
  end
@@ -4,9 +4,9 @@ require_relative '../database'
4
4
 
5
5
  DB.create_table?(:comments) do
6
6
  primary_key :id
7
- foreign_key :author_id, :authors, :on_delete=>:cascade
8
- foreign_key :post_slug, :posts, :on_delete=>:cascade, :type=>String
9
- String :body, :text=>true, :null=>false
7
+ foreign_key :author_id, :authors, on_delete: :cascade
8
+ foreign_key :post_slug, :posts, type: String, on_delete: :cascade, on_update: :cascade
9
+ String :body, text: true, null: false
10
10
  DateTime :created_at
11
11
  DateTime :updated_at
12
12
  end
@@ -37,12 +37,14 @@ CommentController = proc do
37
37
  end
38
38
 
39
39
  def role
40
- if resource&.author == current_user
41
- super.push(:owner)
42
- else
43
- super
40
+ [*super].tap do |a|
41
+ a << :owner if resource&.author == current_user
44
42
  end
45
43
  end
44
+
45
+ def settable_fields
46
+ %i[body]
47
+ end
46
48
  end
47
49
 
48
50
  show do |id|
@@ -51,13 +53,13 @@ CommentController = proc do
51
53
 
52
54
  create(roles: :logged_in) do |attr|
53
55
  comment = Comment.new
54
- comment.set_fields(attr, %i[body])
56
+ comment.set_fields(attr, settable_fields)
55
57
  comment.save(validate: false)
56
58
  next_pk comment
57
59
  end
58
60
 
59
61
  update(roles: %i[owner superuser]) do |attr|
60
- resource.update_fields(attr, %i[body], validate: false, missing: :skip)
62
+ resource.update_fields(attr, settable_fields, validate: false, missing: :skip)
61
63
  end
62
64
 
63
65
  destroy(roles: %i[owner superuser]) do
@@ -3,10 +3,10 @@ require_relative '../base'
3
3
  require_relative '../database'
4
4
 
5
5
  DB.create_table?(:posts) do
6
- String :slug, :primary_key=>true
7
- foreign_key :author_id, :authors, :on_delete=>:cascade
8
- String :title, :null=>false
9
- String :body, :text=>true, :null=>false
6
+ String :slug, primary_key: true
7
+ foreign_key :author_id, :authors, on_delete: :cascade
8
+ String :title, null: false
9
+ String :body, text: true, null: false
10
10
  DateTime :created_at
11
11
  DateTime :updated_at
12
12
  end
@@ -14,11 +14,11 @@ end
14
14
  class Post < Sequel::Model
15
15
  plugin :timestamps
16
16
 
17
- unrestrict_primary_key
17
+ unrestrict_primary_key # allow client-generated slugs
18
18
 
19
19
  many_to_one :author
20
20
  one_to_many :comments
21
- many_to_many :tags, :left_key=>:post_slug
21
+ many_to_many :tags, left_key: :post_slug
22
22
 
23
23
  def validate
24
24
  super
@@ -45,33 +45,38 @@ PostController = proc do
45
45
  end
46
46
 
47
47
  def role
48
- if resource&.author == current_user
49
- super.push(:owner)
50
- else
51
- super
48
+ [*super].tap do |a|
49
+ a << :owner if resource&.author == current_user
52
50
  end
53
51
  end
52
+
53
+ def settable_fields
54
+ %i[title body]
55
+ end
54
56
  end
55
57
 
56
58
  show do |slug|
57
59
  next find(slug), include: %w[author comments tags]
58
60
  end
59
61
 
62
+ show_many do |slugs|
63
+ next Post.where(slug: slugs.map!(&:to_s)).all, include: %i[author tags]
64
+ end
65
+
60
66
  index do
61
- # TODO: Filter/sort by created_at and/or updated_at?
62
- Post.all
67
+ Post.dataset
63
68
  end
64
69
 
65
70
  create(roles: :logged_in) do |attr, slug|
66
71
  post = Post.new
67
- post.set_fields(attr, %i[title body])
72
+ post.set_fields(attr, settable_fields)
68
73
  post.slug = slug.to_s # set primary key
69
74
  post.save(validate: false)
70
75
  next_pk post
71
76
  end
72
77
 
73
78
  update(roles: %i[owner superuser]) do |attr|
74
- resource.update_fields(attr, %i[title body], validate: false, missing: :skip)
79
+ resource.update_fields(attr, settable_fields, validate: false, missing: :skip)
75
80
  end
76
81
 
77
82
  destroy(roles: %i[owner superuser]) do
@@ -94,13 +99,13 @@ PostController = proc do
94
99
 
95
100
  has_many :comments do
96
101
  fetch do
97
- next resource.comments, include: 'author'
102
+ next resource.comments_dataset, include: 'author'
98
103
  end
99
104
  end
100
105
 
101
106
  has_many :tags do
102
107
  fetch do
103
- resource.tags
108
+ resource.tags_dataset
104
109
  end
105
110
 
106
111
  merge(roles: %i[owner superuser], sideload_on: %i[create update]) do |rios|
@@ -6,18 +6,18 @@ require_relative 'post' # make sure we create the posts table before the join ta
6
6
 
7
7
  DB.create_table?(:tags) do
8
8
  primary_key :id
9
- String :name, :null=>false, :unique=>true
9
+ String :name, null: false, unique: true
10
10
  end
11
11
 
12
12
  DB.create_table?(:posts_tags) do
13
- foreign_key :post_slug, :posts, :null=>false, :on_delete=>:cascade, :type=>String
14
- foreign_key :tag_id, :tags, :null=>false, :on_delete=>:cascade
13
+ foreign_key :post_slug, :posts, type: String, null: false, on_delete: :cascade, on_update: :cascade
14
+ foreign_key :tag_id, :tags, null: false, on_delete: :cascade
15
15
  primary_key [:post_slug, :tag_id]
16
16
  index [:tag_id, :post_slug]
17
17
  end
18
18
 
19
19
  class Tag < Sequel::Model
20
- many_to_many :posts
20
+ many_to_many :posts, right_key: :post_slug
21
21
  end
22
22
 
23
23
  class TagSerializer < BaseSerializer
@@ -31,17 +31,21 @@ TagController = proc do
31
31
  def find(id)
32
32
  Tag[id.to_i]
33
33
  end
34
+
35
+ def settable_fields
36
+ %i[name]
37
+ end
34
38
  end
35
39
 
36
40
  show
37
41
 
38
- index do
39
- Tag.all
42
+ index(sort_by: :name, filter_by: :name) do
43
+ Tag.dataset
40
44
  end
41
45
 
42
46
  create(roles: :logged_in) do |attr|
43
47
  tag = Tag.new
44
- tag.set_fields(attr, %i[name])
48
+ tag.set_fields(attr, settable_fields)
45
49
  tag.save(validate: false)
46
50
  next_pk tag
47
51
  end
@@ -52,7 +56,7 @@ TagController = proc do
52
56
 
53
57
  has_many :posts do
54
58
  fetch do
55
- resource.posts
59
+ resource.posts_dataset
56
60
  end
57
61
 
58
62
  merge(roles: :logged_in) do |rios|
data/demo-app/database.rb CHANGED
@@ -2,11 +2,9 @@
2
2
  require 'logger'
3
3
  require_relative 'boot'
4
4
 
5
- DB =
6
- if defined?(JRUBY_VERSION)
7
- Sequel.connect 'jdbc:sqlite::memory:'
8
- else
9
- Sequel.sqlite
10
- end
5
+ DB = Sequel.connect ENV.fetch 'DATABASE_URL',
6
+ defined?(JRUBY_VERSION) ? 'jdbc:sqlite::memory:' : 'sqlite:/'
7
+
8
+ DB.extension :pagination
11
9
 
12
10
  DB.loggers << Logger.new($stderr) if Sinatra::Base.development?
data/lib/sinja/config.rb CHANGED
@@ -15,7 +15,21 @@ module Sinja
15
15
  end
16
16
 
17
17
  def deep_freeze(c)
18
- c.tap { |i| i.values.each(&:freeze) }.freeze
18
+ if c.respond_to?(:default_proc)
19
+ c.default_proc = nil
20
+ end
21
+
22
+ if c.respond_to?(:values)
23
+ c.values.each do |i|
24
+ if Hash === i
25
+ deep_freeze(i)
26
+ else
27
+ i.freeze
28
+ end
29
+ end
30
+ end
31
+
32
+ c.freeze
19
33
  end
20
34
  end
21
35
 
@@ -33,41 +47,54 @@ module Sinja
33
47
  }.freeze
34
48
 
35
49
  attr_reader \
50
+ :query_params,
36
51
  :error_logger,
37
- :default_roles,
38
- :default_has_many_roles,
39
- :default_has_one_roles,
40
- :resource_roles,
41
- :resource_sideload,
52
+ :resource_config,
42
53
  :conflict_exceptions,
43
54
  :not_found_exceptions,
44
55
  :validation_exceptions,
45
56
  :validation_formatter,
57
+ :page_using,
46
58
  :serializer_opts
47
59
 
48
60
  def initialize
49
- @error_logger = ->(eh) { logger.error('sinja') { eh } }
50
-
51
- @default_roles = RolesConfig.new(ResourceRoutes::ACTIONS)
52
- @default_has_many_roles = RolesConfig.new(RelationshipRoutes::HasMany::ACTIONS)
53
- @default_has_one_roles = RolesConfig.new(RelationshipRoutes::HasOne::ACTIONS)
54
-
55
- @resource_roles = Hash.new { |h, k| h[k] = {
56
- :resource=>@default_roles.dup,
57
- :has_many=>Hash.new { |rh, rk| rh[rk] = @default_has_many_roles.dup },
58
- :has_one=>Hash.new { |rh, rk| rh[rk] = @default_has_one_roles.dup }
61
+ @query_params = {
62
+ :fields=>{}, # passthru to JAS
63
+ :include=>[], # passthru to JAS
64
+ :filter=>{},
65
+ :page=>{},
66
+ :sort=>[],
67
+ :capture=>nil
68
+ }
69
+
70
+ @error_logger = ->(h) { logger.error('sinja') { h } }
71
+
72
+ @default_roles = {
73
+ :resource=>RolesConfig.new(%i[show show_many index create update destroy]),
74
+ :has_many=>RolesConfig.new(%i[fetch merge subtract clear]),
75
+ :has_one=>RolesConfig.new(%i[pluck graft prune])
76
+ }
77
+
78
+ action_proc = proc { |type, hash, action| hash[action] = {
79
+ :roles=>@default_roles[type][action].dup,
80
+ :sideload_on=>Set.new,
81
+ :filter_by=>Set.new,
82
+ :sort_by=>Set.new
83
+ }}.curry
84
+
85
+ @resource_config = Hash.new { |h, k| h[k] = {
86
+ :resource=>Hash.new(&action_proc[:resource]),
87
+ :has_many=>Hash.new { |rh, rk| rh[rk] = Hash.new(&action_proc[:has_many]) },
88
+ :has_one=>Hash.new { |rh, rk| rh[rk] = Hash.new(&action_proc[:has_one]) }
59
89
  }}
60
90
 
61
- @resource_sideload = Hash.new do |h, k|
62
- h[k] = SideloadConfig.new(Resource::SIDELOAD_ACTIONS)
63
- end
64
-
65
91
  @conflict_exceptions = Set.new
66
92
  @not_found_exceptions = Set.new
67
93
  @validation_exceptions = Set.new
68
94
  @validation_formatter = ->{ Array.new }
69
95
 
70
96
  @opts = deep_copy(DEFAULT_OPTS)
97
+ @page_using = Hash.new
71
98
  @serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS)
72
99
  end
73
100
 
@@ -82,15 +109,15 @@ module Sinja
82
109
  end
83
110
 
84
111
  def conflict_exceptions=(e=[])
85
- @conflict_exceptions.replace(Set[*e])
112
+ @conflict_exceptions.replace([*e])
86
113
  end
87
114
 
88
115
  def not_found_exceptions=(e=[])
89
- @not_found_exceptions.replace(Set[*e])
116
+ @not_found_exceptions.replace([*e])
90
117
  end
91
118
 
92
119
  def validation_exceptions=(e=[])
93
- @validation_exceptions.replace(Set[*e])
120
+ @validation_exceptions.replace([*e])
94
121
  end
95
122
 
96
123
  def validation_formatter=(f)
@@ -103,12 +130,28 @@ module Sinja
103
130
  @validation_formatter = f
104
131
  end
105
132
 
106
- def_delegator :@default_roles, :merge!, :default_roles=
107
- def_delegator :@default_has_many_roles, :merge!, :default_has_many_roles=
108
- def_delegator :@default_has_one_roles, :merge!, :default_has_one_roles=
133
+ def default_roles
134
+ @default_roles[:resource]
135
+ end
109
136
 
110
- def serializer_opts=(h={})
111
- @serializer_opts.replace(deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h))
137
+ def default_roles=(other={})
138
+ @default_roles[:resource].merge!(other)
139
+ end
140
+
141
+ def default_has_many_roles
142
+ @default_roles[:has_many]
143
+ end
144
+
145
+ def default_has_many_roles=(other={})
146
+ @default_roles[:has_many].merge!(other)
147
+ end
148
+
149
+ def default_has_one_roles
150
+ @default_roles[:has_one]
151
+ end
152
+
153
+ def default_has_one_roles=(other={})
154
+ @default_roles[:has_one].merge!(other)
112
155
  end
113
156
 
114
157
  DEFAULT_OPTS.keys.each do |k|
@@ -116,34 +159,29 @@ module Sinja
116
159
  define_method("#{k}=") { |v| @opts[k] = v }
117
160
  end
118
161
 
162
+ def page_using=(p={})
163
+ @page_using.replace(p)
164
+ end
165
+
166
+ def serializer_opts=(h={})
167
+ @serializer_opts.replace(deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h))
168
+ end
169
+
119
170
  def freeze
171
+ @query_params.freeze
120
172
  @error_logger.freeze
121
173
 
122
- @default_roles.freeze
123
- @default_has_many_roles.freeze
124
- @default_has_one_roles.freeze
125
-
126
- @resource_roles.default_proc = nil
127
- @resource_roles.values.each do |h|
128
- h[:resource].freeze
129
- h[:has_many].default_proc = nil
130
- deep_freeze(h[:has_many])
131
- h[:has_one].default_proc = nil
132
- deep_freeze(h[:has_one])
133
- end
134
- deep_freeze(@resource_roles)
135
-
136
- @resource_sideload.default_proc = nil
137
- deep_freeze(@resource_sideload)
174
+ deep_freeze(@default_roles)
175
+ deep_freeze(@resource_config)
138
176
 
139
177
  @conflict_exceptions.freeze
140
178
  @not_found_exceptions.freeze
141
179
  @validation_exceptions.freeze
142
180
  @validation_formatter.freeze
143
181
 
144
- deep_freeze(@serializer_opts)
145
-
146
182
  @opts.freeze
183
+ @page_using.freeze
184
+ deep_freeze(@serializer_opts)
147
185
 
148
186
  super
149
187
  end
@@ -173,7 +211,7 @@ module Sinja
173
211
  h.each do |action, roles|
174
212
  abort "Unknown or invalid action helper `#{action}' in configuration" \
175
213
  unless @data.key?(action)
176
- @data[action].replace(Roles[*roles])
214
+ @data[action].replace([*roles])
177
215
  end
178
216
  @data
179
217
  end
@@ -188,33 +226,4 @@ module Sinja
188
226
  super
189
227
  end
190
228
  end
191
-
192
- class SideloadConfig
193
- include ConfigUtils
194
- extend Forwardable
195
-
196
- def initialize(actions=[])
197
- @data = actions.map { |child| [child, Set.new] }.to_h
198
- end
199
-
200
- def_delegators :@data, :[], :dig
201
-
202
- def ==(other)
203
- @data == other.instance_variable_get(:@data)
204
- end
205
-
206
- def merge!(h={})
207
- h.each do |child, parents|
208
- abort "Unknown or invalid action helper `#{child}' in configuration" \
209
- unless @data.key?(child)
210
- @data[child].replace(Set[*parents])
211
- end
212
- @data
213
- end
214
-
215
- def freeze
216
- deep_freeze(@data)
217
- super
218
- end
219
- end
220
229
  end
@@ -19,9 +19,11 @@ module Sinja
19
19
  end
20
20
 
21
21
  def dispatch_relationship_requests!(id, methods: {}, **opts)
22
- data.fetch(:relationships, {}).each do |path, body|
23
- method = methods.fetch(settings._resource_roles[:has_one].key?(path.to_sym) ? :has_one : :has_many, :patch)
24
- code, _, *json = dispatch_relationship_request(id, path, opts.merge(:body=>body, :method=>method))
22
+ data.fetch(:relationships, {}).each do |rel, body|
23
+ rel_type = settings._resource_config[:has_one].key?(rel) ? :has_one : :has_many
24
+ code, _, *json = dispatch_relationship_request id, rel,
25
+ opts.merge(:body=>body, :method=>methods.fetch(rel_type, :patch))
26
+
25
27
  # TODO: Gather responses and report all errors instead of only first?
26
28
  # `halt' was called (instead of raise); rethrow it as best as possible
27
29
  raise SideloadError.new(code, json) unless (200...300).cover?(code)