radical 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile +4 -2
  4. data/README.md +1 -1
  5. data/exe/rad +41 -0
  6. data/lib/radical/app.rb +64 -12
  7. data/lib/radical/asset.rb +24 -0
  8. data/lib/radical/asset_compiler.rb +40 -0
  9. data/lib/radical/assets.rb +45 -0
  10. data/lib/radical/controller.rb +52 -11
  11. data/lib/radical/database.rb +1 -1
  12. data/lib/radical/flash.rb +61 -0
  13. data/lib/radical/form.rb +73 -15
  14. data/lib/radical/generator/app/.env +5 -0
  15. data/lib/radical/generator/app/Gemfile +7 -0
  16. data/lib/radical/generator/app/app.rb +37 -0
  17. data/lib/radical/generator/app/config.ru +5 -0
  18. data/lib/radical/generator/app/controllers/controller.rb +4 -0
  19. data/lib/radical/generator/app/models/model.rb +4 -0
  20. data/lib/radical/generator/app/routes.rb +5 -0
  21. data/lib/radical/generator/blank_migration.rb +11 -0
  22. data/lib/radical/generator/controller.rb +59 -0
  23. data/lib/radical/generator/migration.rb +13 -0
  24. data/lib/radical/generator/model.rb +9 -0
  25. data/lib/radical/generator/views/_form.rb +6 -0
  26. data/lib/radical/generator/views/edit.rb +3 -0
  27. data/lib/radical/generator/views/index.rb +24 -0
  28. data/lib/radical/generator/views/new.rb +4 -0
  29. data/lib/radical/generator/views/show.rb +5 -0
  30. data/lib/radical/generator.rb +155 -0
  31. data/lib/radical/model.rb +1 -1
  32. data/lib/radical/router.rb +142 -43
  33. data/lib/radical/routes.rb +17 -6
  34. data/lib/radical/security_headers.rb +27 -0
  35. data/lib/radical/strings.rb +17 -0
  36. data/lib/radical/view.rb +17 -9
  37. data/lib/radical.rb +7 -0
  38. data/radical.gemspec +3 -2
  39. metadata +41 -16
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Controller < Radical::Controller
4
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Model < Radical::Model
4
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Routes < Radical::Routes
4
+ # root :Home
5
+ end
@@ -0,0 +1,11 @@
1
+ <<~RB
2
+ # frozen_string_literal: true
3
+
4
+ class #{@name.capitalize} < Radical::Migration
5
+ up do
6
+ end
7
+
8
+ down do
9
+ end
10
+ end
11
+ RB
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ <<~RB
4
+ # frozen_string_literal: true
5
+
6
+ class #{plural_constant} < Controller
7
+ def index
8
+ @#{plural} = #{singular_constant}.all
9
+ end
10
+
11
+ def show; end
12
+
13
+ def new
14
+ @#{singular} = #{singular_constant}.new
15
+ end
16
+
17
+ def create
18
+ @#{singular} = #{singular_constant}.new(#{singular}_params)
19
+
20
+ if @#{singular}.save
21
+ flash[:success] = '#{singular_constant} created'
22
+ redirect #{plural}_path
23
+ else
24
+ render :new
25
+ end
26
+ end
27
+
28
+ def edit; end
29
+
30
+ def update
31
+ if #{singular}.update(#{singular}_params)
32
+ flash[:success] = '#{singular_constant} updated'
33
+ redirect #{plural}_path
34
+ else
35
+ render :edit
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ if #{singular}.destroy
41
+ flash[:success] = '#{singular_constant} destroyed'
42
+ else
43
+ flash[:error] = 'Error destroying #{singular_constant}'
44
+ end
45
+
46
+ redirect #{plural}_path
47
+ end
48
+
49
+ private
50
+
51
+ def #{singular}_params
52
+ params['#{singular}'].slice(#{params})
53
+ end
54
+
55
+ def #{singular}
56
+ @#{singular} = #{singular_constant}.find(params['id'])
57
+ end
58
+ end
59
+ RB
@@ -0,0 +1,13 @@
1
+ <<~RB
2
+ # frozen_string_literal: true
3
+
4
+ class CreateTable#{plural_constant} < Radical::Migration
5
+ change do
6
+ create_table :#{plural} do |t|
7
+ #{columns(leading: 8)}
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
13
+ RB
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ <<~RB
4
+ # frozen_string_literal: true
5
+
6
+ class #{singular_constant} < Model
7
+ table '#{plural}'
8
+ end
9
+ RB
@@ -0,0 +1,6 @@
1
+ <<~ERB
2
+ <%== form model: #{singular} do |f| %>
3
+ #{inputs(leading: 4)}
4
+ <%== f.submit 'Save' %>
5
+ <% end %>
6
+ ERB
@@ -0,0 +1,3 @@
1
+ <<~ERB
2
+ <%== partial 'form' %>
3
+ ERB
@@ -0,0 +1,24 @@
1
+ <<~ERB
2
+ <a href="<%= new_#{plural}_path %>">New #{singular}</a>
3
+
4
+ <table>
5
+ <thead>
6
+ #{th(leading: 6)}
7
+ <th></th>
8
+ </thead>
9
+ <tbody>
10
+ <% @#{plural}.each do |#{singular}| %>
11
+ <tr>
12
+ #{td(leading: 10)}
13
+ <td>
14
+ <a href="<%= #{plural}_path(#{singular}) %>">show</a>
15
+ <a href="<%= edit_#{plural}_path(#{singular}) %>">edit</a>
16
+ <%== form model: #{singular}, method: :delete do |f| %>
17
+ <%== f.submit 'delete' %>
18
+ <% end %>
19
+ </td>
20
+ </tr>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
24
+ ERB
@@ -0,0 +1,4 @@
1
+ <<~ERB
2
+ <%== partial 'form' %>
3
+ ERB
4
+
@@ -0,0 +1,5 @@
1
+ <<~ERB
2
+ <%= #{singular}.name %>
3
+
4
+ <a href="<%== #{plural}_path %>">Index</a>
5
+ ERB
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+
6
+ module Radical
7
+ class Generator
8
+ def initialize(name, props)
9
+ @name = name
10
+ @props = props
11
+ end
12
+
13
+ def mvc
14
+ migration
15
+ model
16
+ views
17
+ controller
18
+ end
19
+
20
+ def migration(model: true)
21
+ dir = File.join(Dir.pwd, 'migrations')
22
+ FileUtils.mkdir_p dir
23
+
24
+ template = instance_eval File.read(File.join(__dir__, 'generator', "#{model ? '' : 'blank_'}migration.rb"))
25
+ migration_name = model ? "#{Time.now.to_i}_create_table_#{plural}.rb" : "#{Time.now.to_i}_#{@name}.rb"
26
+ filename = File.join(dir, migration_name)
27
+
28
+ write(filename, template)
29
+ end
30
+
31
+ def model
32
+ template = instance_eval File.read File.join(__dir__, 'generator', 'model.rb')
33
+ dir = File.join(Dir.pwd, 'models')
34
+ FileUtils.mkdir_p dir
35
+ filename = File.join(dir, "#{singular}.rb")
36
+
37
+ write(filename, template)
38
+ end
39
+
40
+ def controller
41
+ template = instance_eval File.read File.join(__dir__, 'generator', 'controller.rb')
42
+ dir = File.join(Dir.pwd, 'controllers')
43
+ FileUtils.mkdir_p dir
44
+ filename = File.join(dir, "#{plural}.rb")
45
+
46
+ write(filename, template)
47
+ end
48
+
49
+ def views
50
+ dir = File.join(Dir.pwd, 'views', plural)
51
+ FileUtils.mkdir_p dir
52
+
53
+ Dir[File.join(__dir__, 'generator', 'views', '*.rb')].sort.each do |template|
54
+ contents = instance_eval File.read template
55
+ filename = File.join(dir, "#{File.basename(template, '.rb')}.erb")
56
+
57
+ write(filename, contents)
58
+ end
59
+ end
60
+
61
+ def app
62
+ @name = nil if @name == '.'
63
+ parts = [Dir.pwd, @name].compact
64
+ dir = File.join(*parts)
65
+ FileUtils.mkdir_p dir
66
+
67
+ %w[
68
+ assets/css
69
+ assets/js
70
+ controllers
71
+ migrations
72
+ models
73
+ views
74
+ ].each do |dir_|
75
+ puts "Creating directory #{dir_}"
76
+ FileUtils.mkdir_p File.join(dir, dir_)
77
+ end
78
+
79
+ Dir[File.join(__dir__, 'generator', 'app', '**', '*.*')].sort.each do |template|
80
+ contents = File.read(template)
81
+ filename = File.join(dir, File.path(template).gsub("#{__dir__}/generator/app/", ''))
82
+
83
+ write(filename, contents)
84
+ end
85
+
86
+ # Explicitly include .env
87
+ template = File.join(__dir__, 'generator', 'app', '.env')
88
+ contents = instance_eval File.read(template)
89
+ filename = File.join(dir, '.env')
90
+ write(filename, contents)
91
+ end
92
+
93
+ private
94
+
95
+ def write(filename, contents)
96
+ if File.exist?(filename)
97
+ puts "Skipped #{File.basename(filename)}"
98
+ else
99
+ File.write(filename, contents)
100
+ puts "Created #{filename}"
101
+ end
102
+ end
103
+
104
+ def singular_constant
105
+ @name.gsub(/\(.*\)/, '')
106
+ end
107
+
108
+ def plural_constant
109
+ @name.gsub(/[)(]/, '')
110
+ end
111
+
112
+ def singular
113
+ Strings.camel_case singular_constant
114
+ end
115
+
116
+ def plural
117
+ Strings.camel_case plural_constant
118
+ end
119
+
120
+ def columns(leading:)
121
+ @props
122
+ .map { |p| p.split(':') }
123
+ .map { |name, type| "t.#{type} #{name}" }
124
+ .join "#{' ' * leading}\n"
125
+ end
126
+
127
+ def th(leading:)
128
+ @props
129
+ .map { |p| p.split(':').first }
130
+ .map { |name| "<th>#{name}</th>" }
131
+ .join "#{' ' * leading}\n"
132
+ end
133
+
134
+ def td(leading:)
135
+ @props
136
+ .map { |p| p.split(':').first }
137
+ .map { |name| "<td><%= #{singular}.#{name} %></td>" }
138
+ .join "#{' ' * leading}\n"
139
+ end
140
+
141
+ def inputs(leading:)
142
+ @props
143
+ .map { |p| p.split(':').first }
144
+ .map { |name| "<%== f.text :#{name} %>" }
145
+ .join "#{' ' * leading}\n"
146
+ end
147
+
148
+ def params
149
+ @props
150
+ .map { |p| p.split(':').first }
151
+ .map { |name| "'#{name}'" }
152
+ .join ', '
153
+ end
154
+ end
155
+ end
data/lib/radical/model.rb CHANGED
@@ -49,7 +49,7 @@ module Radical
49
49
  def initialize(params = {})
50
50
  columns.each do |column|
51
51
  self.class.attr_accessor column.to_sym
52
- instance_variable_set "@#{column}", params[column]
52
+ instance_variable_set "@#{column}", (params[column] || params[column.to_sym])
53
53
  end
54
54
  end
55
55
 
@@ -1,5 +1,5 @@
1
- # frozen_string_literal: true
2
1
  # typed: true
2
+ # frozen_string_literal: true
3
3
 
4
4
  require 'rack'
5
5
  require 'sorbet-runtime'
@@ -47,6 +47,8 @@ module Radical
47
47
  ].freeze
48
48
 
49
49
  RESOURCE_ACTIONS = [
50
+ [:new, 'GET', '/new'],
51
+ [:create, 'POST', ''],
50
52
  [:show, 'GET', ''],
51
53
  [:edit, 'GET', '/edit'],
52
54
  [:update, 'PUT', ''],
@@ -68,54 +70,31 @@ module Radical
68
70
 
69
71
  sig { params(klass: Class).void }
70
72
  def add_root(klass)
71
- add_actions(klass, name: '')
73
+ add_routes(klass, name: '', actions: ACTIONS)
74
+ add_root_paths(klass)
72
75
  end
73
76
 
74
- sig { params(klass: Class, name: T.nilable(String), prefix: T.nilable(String), actions: Array).void }
75
- def add_actions(klass, name: nil, prefix: nil, actions: ACTIONS)
76
- name ||= klass.route_name
77
-
78
- actions.each do |method, http_method, suffix|
79
- next unless klass.method_defined?(method)
80
-
81
- path = "/#{prefix}#{name}#{suffix}"
82
- path = Regexp.new("^#{path.gsub(/:(\w+)/, '(?<\1>[a-zA-Z0-9_]+)')}$").freeze
83
-
84
- if %i[index create show update destroy].include?(method) && !klass.method_defined?(:"#{klass.route_name}_path")
85
- klass.define_method :"#{klass.route_name}_path" do |obj = nil|
86
- if obj.is_a?(Model)
87
- "/#{klass.route_name}/#{obj.id}"
88
- else
89
- "/#{klass.route_name}"
90
- end
91
- end
92
- end
93
-
94
- if method == :new
95
- klass.define_method :"new_#{klass.route_name}_path" do
96
- "/#{klass.route_name}/new"
97
- end
98
- end
99
-
100
- if method == :edit
101
- klass.define_method :"edit_#{klass.route_name}_path" do |obj|
102
- "/#{klass.route_name}/#{obj.id}/edit"
103
- end
104
- end
105
-
106
- @routes[http_method] << [path, [klass, method]]
107
- end
77
+ sig { params(klass: Class).void }
78
+ def add_resource(klass)
79
+ add_routes(klass, actions: RESOURCE_ACTIONS)
80
+ add_resource_paths(klass)
108
81
  end
109
82
 
110
- sig { params(classes: T::Array[Class], prefix: T.nilable(String), actions: Array).void }
111
- def add_routes(classes, prefix: nil, actions: ACTIONS)
112
- classes.each do |klass|
113
- add_actions(klass, prefix: prefix, actions: actions)
83
+ sig { params(klass: Class, parents: T.nilable(T::Array[Class])).void }
84
+ def add_resources(klass, parents: nil)
85
+ if parents
86
+ parents.each do |scope|
87
+ add_routes(klass, actions: ACTIONS, scope: scope)
88
+ add_resources_paths(klass, scope: scope)
89
+ end
90
+ else
91
+ add_routes(klass, actions: ACTIONS)
92
+ add_resources_paths(klass)
114
93
  end
115
94
  end
116
95
 
117
- sig { params(request: Rack::Request).returns(Rack::Response) }
118
- def route(request)
96
+ sig { params(request: Rack::Request, options: T.nilable(Hash)).returns(Rack::Response) }
97
+ def route(request, options: {})
119
98
  params = T.let({}, T.nilable(Hash))
120
99
 
121
100
  route = @routes[request.request_method].find do |r|
@@ -130,7 +109,7 @@ module Radical
130
109
  request.update_param(k, v)
131
110
  end
132
111
 
133
- instance = klass.new(request)
112
+ instance = klass.new(request, options: options)
134
113
 
135
114
  response = instance.public_send(method)
136
115
 
@@ -142,5 +121,125 @@ module Radical
142
121
 
143
122
  Rack::Response.new(body, 200, { 'Content-Type' => 'text/html' })
144
123
  end
124
+
125
+ private
126
+
127
+ sig { params(klass: Class, actions: Array, name: T.nilable(String), scope: T.nilable(Class)).void }
128
+ def add_routes(klass, actions:, name: nil, scope: nil)
129
+ name ||= klass.route_name
130
+
131
+ actions.each do |method, http_method, suffix|
132
+ next unless klass.method_defined?(method)
133
+
134
+ path = if scope
135
+ if %i[index new create].include?(method)
136
+ "/#{scope.route_name}/:#{scope.route_name}_id/#{name}#{suffix}"
137
+ else
138
+ "/#{name}#{suffix}"
139
+ end
140
+ else
141
+ "/#{name}#{suffix}"
142
+ end
143
+
144
+ path = Regexp.new("^#{path.gsub(/:(\w+)/, '(?<\1>[a-zA-Z0-9_]+)')}$").freeze
145
+
146
+ @routes[http_method] << [path, [klass, method]]
147
+ end
148
+ end
149
+
150
+ sig { params(klass: Class).void }
151
+ def add_root_paths(klass)
152
+ route_name = klass.route_name
153
+
154
+ if %i[index create show update destroy].any? { |method| klass.method_defined?(method) }
155
+ Controller.define_method :"#{route_name}_path" do |obj = nil|
156
+ if obj
157
+ "/#{obj.id}"
158
+ else
159
+ '/'
160
+ end
161
+ end
162
+ end
163
+
164
+ if klass.method_defined?(:new)
165
+ Controller.define_method :"new_#{route_name}_path" do
166
+ '/new'
167
+ end
168
+ end
169
+
170
+ return unless klass.method_defined?(:edit)
171
+
172
+ Controller.define_method :"edit_#{route_name}_path" do |obj|
173
+ "/#{obj.id}/edit"
174
+ end
175
+ end
176
+
177
+ sig { params(klass: Class).void }
178
+ def add_resource_paths(klass)
179
+ name = klass.route_name
180
+
181
+ if %i[create show update destroy].any? { |method| klass.method_defined?(method) }
182
+ Controller.define_method :"#{name}_path" do
183
+ "/#{name}"
184
+ end
185
+ end
186
+
187
+ if klass.method_defined?(:new)
188
+ Controller.define_method :"new_#{name}_path" do
189
+ "/#{name}/new"
190
+ end
191
+ end
192
+
193
+ return unless klass.method_defined?(:edit)
194
+
195
+ Controller.define_method :"edit_#{name}_path" do
196
+ "/#{name}/edit"
197
+ end
198
+ end
199
+
200
+ sig { params(klass: Class, scope: T.nilable(Class)).void }
201
+ def add_resources_paths(klass, scope: nil)
202
+ path_name = klass.route_name
203
+ scope_path_name = [scope&.route_name, klass.route_name].compact.join('_')
204
+ name = klass.route_name
205
+
206
+ if %i[index create show update destroy].any? { |method| klass.method_defined?(method) }
207
+ if scope
208
+ Controller.define_method :"#{scope_path_name}_path" do |parent|
209
+ "/#{scope.route_name}/#{parent.id}/#{name}"
210
+ end
211
+
212
+ Controller.define_method :"#{path_name}_path" do |obj|
213
+ "/#{name}/#{obj.id}"
214
+ end
215
+ else
216
+ Controller.define_method :"#{path_name}_path" do |obj = nil|
217
+ if obj
218
+ "/#{name}/#{obj.id}"
219
+ else
220
+ "/#{name}"
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ if klass.method_defined?(:new)
227
+ if scope
228
+ Controller.define_method :"new_#{scope_path_name}_path" do |parent|
229
+ "/#{scope.route_name}/#{parent.id}/#{name}/new"
230
+ end
231
+ else
232
+ Controller.define_method :"new_#{path_name}_path" do
233
+ "/#{name}/new"
234
+ end
235
+ end
236
+ end
237
+
238
+ return unless klass.method_defined?(:edit)
239
+
240
+ Controller.define_method :"edit_#{path_name}_path" do |obj|
241
+ "/#{name}/#{obj.id}/edit"
242
+ end
243
+ end
145
244
  end
146
245
  end
@@ -13,6 +13,10 @@ module Radical
13
13
  @router ||= Router.new
14
14
  end
15
15
 
16
+ def parents
17
+ @parents ||= []
18
+ end
19
+
16
20
  sig { params(name: T.any(String, Symbol)).void }
17
21
  def root(name)
18
22
  klass = Object.const_get(name)
@@ -25,7 +29,7 @@ module Radical
25
29
  classes = names.map { |c| Object.const_get(c) }
26
30
 
27
31
  classes.each do |klass|
28
- router.add_actions(klass, actions: Router::RESOURCE_ACTIONS)
32
+ router.add_resource(klass)
29
33
  end
30
34
  end
31
35
 
@@ -33,14 +37,21 @@ module Radical
33
37
  def resources(*names, &block)
34
38
  classes = names.map { |c| Object.const_get(c) }
35
39
 
36
- prefix = "#{router.route_prefix(@parents)}/" if instance_variable_defined?(:@parents)
37
-
38
- router.add_routes(classes, prefix: prefix)
40
+ classes.each do |klass|
41
+ if parents.any?
42
+ router.add_resources(klass, parents: @parents)
43
+
44
+ # only one level of nesting
45
+ @parents = []
46
+ else
47
+ router.add_resources(klass)
48
+ end
49
+ end
39
50
 
40
51
  return unless block
41
52
 
42
- @parents ||= []
43
- @parents << classes.last
53
+ @parents = classes
54
+
44
55
  block.call
45
56
  end
46
57
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
4
+ class SecurityHeaders
5
+ DEFAULT_HEADERS = {
6
+ 'X-Content-Type-Options' => 'nosniff',
7
+ 'X-Frame-Options' => 'deny',
8
+ 'X-XSS-Protection' => '1; mode=block',
9
+ 'X-Permitted-Cross-Domain-Policies' => 'none',
10
+ 'Strict-Transport-Security' => 'max-age=31536000;, max-age=31536000; includeSubdomains',
11
+ 'Content-Security-Policy' => "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; block-all-mixed-content;"
12
+ }.freeze
13
+
14
+ def initialize(app, headers)
15
+ @app = app
16
+ @headers = DEFAULT_HEADERS.merge(headers)
17
+ end
18
+
19
+ def call(env)
20
+ @app.call(env).tap do |_, headers|
21
+ @headers.each do |k, v|
22
+ headers[k] ||= v
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
4
+ class Strings
5
+ SNAKE_CASE_REGEX = /\B([A-Z])/.freeze
6
+
7
+ class << self
8
+ def snake_case(str)
9
+ str.gsub(SNAKE_CASE_REGEX, '_\1').downcase
10
+ end
11
+
12
+ def camel_case(str)
13
+ str.split('_').map(&:capitalize).join
14
+ end
15
+ end
16
+ end
17
+ end