radical 1.1.0 → 1.2.0

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