sinja 1.0.0.pre2 → 1.1.0.pre1

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