edgeway 0.1.0 → 0.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +270 -9
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare.js +123 -22
  6. data/js/cors.js +15 -16
  7. data/js/headers.js +16 -0
  8. data/js/jwt.js +1 -1
  9. data/lib/edgeway/application.rb +6 -3
  10. data/lib/edgeway/cli/build/app_config.rb +39 -0
  11. data/lib/edgeway/cli/build/app_files.rb +40 -0
  12. data/lib/edgeway/cli/build/database_config.rb +92 -0
  13. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  14. data/lib/edgeway/cli/build.rb +36 -11
  15. data/lib/edgeway/cli/generate.rb +174 -26
  16. data/lib/edgeway/cli/new.rb +1 -1
  17. data/lib/edgeway/cli/test.rb +4 -1
  18. data/lib/edgeway/cli.rb +5 -4
  19. data/lib/edgeway/controller/base.rb +12 -2
  20. data/lib/edgeway/controller/dispatcher.rb +11 -1
  21. data/lib/edgeway/controller/rendering.rb +24 -4
  22. data/lib/edgeway/db/adapter.rb +38 -0
  23. data/lib/edgeway/db/memory.rb +9 -2
  24. data/lib/edgeway/db/query_stats.rb +46 -0
  25. data/lib/edgeway/db/sqlite.rb +73 -9
  26. data/lib/edgeway/errors.rb +22 -0
  27. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  28. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  29. data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
  30. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  31. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  32. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  33. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  34. data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
  35. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  36. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
  37. data/lib/edgeway/http/auth/stub.rb +1 -3
  38. data/lib/edgeway/http/request.rb +2 -22
  39. data/lib/edgeway/inflector.rb +19 -0
  40. data/lib/edgeway/model/attributes.rb +93 -10
  41. data/lib/edgeway/model/base.rb +50 -5
  42. data/lib/edgeway/model/connection_registry.rb +25 -0
  43. data/lib/edgeway/model/macros.rb +5 -1
  44. data/lib/edgeway/model/persistence.rb +18 -11
  45. data/lib/edgeway/model/relation.rb +106 -56
  46. data/lib/edgeway/model/validations.rb +29 -23
  47. data/lib/edgeway/routing/route.rb +1 -6
  48. data/lib/edgeway/server.rb +12 -0
  49. data/lib/edgeway/service/cloudflare/d1.rb +39 -10
  50. data/lib/edgeway/test.rb +29 -8
  51. data/lib/edgeway/version.rb +1 -1
  52. data/lib/edgeway/view.rb +24 -0
  53. data/lib/edgeway.rb +3 -0
  54. metadata +17 -4
  55. data/lib/edgeway/cli/app_config.rb +0 -35
  56. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../inflector"
4
+
5
+ module Edgeway
6
+ module CLI
7
+ class Build
8
+ # app/views/**/*.html.erb -> Ruby that reopens the controllers.
9
+ #
10
+ # Spinel has no eval and no binding, so a template can't be read at
11
+ # runtime: each one becomes an instance method of its controller (it sees
12
+ # the action's @variables, params and helpers as any method of it does),
13
+ # and `edgeway build` / `edgeway test` require the result after the
14
+ # controllers.
15
+ #
16
+ # app/views/todos/index.html.erb TodosController#_view_todos_index
17
+ # app/views/layouts/application.html.erb
18
+ # ApplicationController#_render_layout
19
+ #
20
+ # A controller gets `_render_view(action)` too: a case over its templates,
21
+ # not a send, since with a computed name Spinel's send misses the methods
22
+ # of a subclass (matz/spinel#8592).
23
+ #
24
+ # The ERB it reads is a small subset (stdlib ERB is not used):
25
+ # <%= expr %> expr, HTML-escaped
26
+ # <%== expr %> expr, as it is
27
+ # <% code %> code
28
+ # <%# text %> nothing
29
+ # <%% a literal <%
30
+ # <%- starts a tag and drops the indentation before it
31
+ # -%> ends a tag and drops the newline after it
32
+ # and `<%= yield %>` (or `<%== yield %>`) in the layout is the page, as
33
+ # it is.
34
+ class ViewCompiler
35
+ EXT = ".html.erb"
36
+ LAYOUT = "layouts/application#{EXT}"
37
+ NAME = /\A[a-z_][a-z0-9_]*\z/
38
+
39
+ def initialize(root)
40
+ @root = root
41
+ end
42
+
43
+ # The Ruby for app/views, nil when the app has none.
44
+ def compile
45
+ views = File.join(@root, "app/views")
46
+ return nil unless Dir.exist?(views)
47
+
48
+ files = Dir.glob("**/*#{EXT}", base: views).sort
49
+ layout = files.delete(LAYOUT)
50
+ files.reject! { |f| f.start_with?("layouts/") }
51
+ by_dir = {}
52
+ files.each do |f|
53
+ dir = File.dirname(f)
54
+ raise Error, "app/views/#{f}: a template goes in a controller's directory (app/views/todos/index#{EXT})" if dir == "."
55
+
56
+ (by_dir[dir] ||= []) << f
57
+ end
58
+
59
+ out = ["# Generated by `edgeway build` from app/views; do not edit."]
60
+ out.concat(layout_source(layout)) if layout
61
+ by_dir.each { |dir, fs| out.concat(controller_source(dir, fs)) }
62
+ out.join("\n") + "\n"
63
+ end
64
+
65
+ def template_lines(text, path, layout: false)
66
+ lines = []
67
+ pos = 0
68
+ literal = +""
69
+ # the line of text[counted], advanced as the scan goes
70
+ counted = 0
71
+ line = 1
72
+ while (open = text.index("<%", pos))
73
+ if text[open + 2] == "%"
74
+ literal << text[pos...open] << "<%"
75
+ pos = open + 3
76
+ next
77
+ end
78
+ line += text[counted...open].count("\n")
79
+ counted = open
80
+ close = text.index("%>", open + 2) or raise Error, "#{path}:#{line}: unclosed <%"
81
+ literal << text[pos...open]
82
+ body = text[(open + 2)...close]
83
+ if body.start_with?("-")
84
+ body = body[1..]
85
+ # <%- also drops the indentation before it on its line
86
+ line_start = open > 0 ? (text.rindex("\n", open - 1) || -1) + 1 : 0
87
+ indent = open - line_start
88
+ literal = literal[0...(literal.length - indent)] if indent > 0 && text[line_start...open].strip.empty?
89
+ end
90
+ trim = body.end_with?("-")
91
+ body = body.chomp("-") if trim
92
+ after = close + 2
93
+ if trim && text[after] == "\n"
94
+ after += 1
95
+ elsif trim && text[after, 2] == "\r\n"
96
+ after += 2
97
+ end
98
+ flush(lines, literal)
99
+ code = body.strip
100
+ if body.start_with?("#")
101
+ elsif body.start_with?("==")
102
+ expr = code[2..].strip
103
+ lines << "# #{path}:#{line}" << output(expr, "(", ").to_s", layout, path, line)
104
+ elsif body.start_with?("=")
105
+ expr = code[1..].strip
106
+ lines << "# #{path}:#{line}" << output(expr, "Edgeway::View.escape(", ")", layout, path, line)
107
+ else
108
+ lines << "# #{path}:#{line}" << code
109
+ end
110
+ pos = after
111
+ end
112
+ literal << text[pos..]
113
+ flush(lines, literal)
114
+ lines
115
+ end
116
+
117
+ private
118
+
119
+ # An output tag; the closing paren goes on its own line so that a
120
+ # trailing `# comment` in the tag doesn't swallow it. `yield` is the
121
+ # page in the layout.
122
+ def output(expr, head, tail, layout, path, line)
123
+ if expr == "yield"
124
+ raise Error, "#{path}:#{line}: yield is only for the layout" unless layout
125
+
126
+ return "out << content"
127
+ end
128
+ "out << #{head}#{expr}\n#{tail}"
129
+ end
130
+
131
+ def flush(lines, literal)
132
+ return if literal.empty?
133
+
134
+ lines << "out << #{literal.dump}"
135
+ literal.clear
136
+ end
137
+
138
+ def layout_source(file)
139
+ path = "app/views/#{file}"
140
+ controller_file!("application", path)
141
+ body = template_lines(File.read(File.join(@root, path)), path, layout: true)
142
+ wrap("ApplicationController", method_source("_render_layout(content)", body, path))
143
+ end
144
+
145
+ def controller_source(dir, files)
146
+ controller_file!(dir, "app/views/#{dir}/")
147
+ methods = []
148
+ actions = []
149
+ files.each do |f|
150
+ path = "app/views/#{f}"
151
+ action = File.basename(f).delete_suffix(EXT)
152
+ raise Error, "#{path}: #{action.inspect} is not a valid action name" unless NAME.match?(action)
153
+ dir.split("/").each { |part| raise Error, "#{path}: #{part.inspect} is not a valid controller path" unless NAME.match?(part) }
154
+
155
+ name = "_view_#{dir.tr("/", "_")}_#{action}"
156
+ methods.concat(method_source(name, template_lines(File.read(File.join(@root, path)), path), path))
157
+ methods << ""
158
+ actions << [action, name]
159
+ end
160
+ dispatch = ["def _render_view(action)", " case action"]
161
+ actions.each { |action, name| dispatch << " when #{action.dump} then #{name}" }
162
+ dispatch << " else super" << " end" << "end"
163
+ wrap(Inflector.controller_class_name(dir), methods + dispatch)
164
+ end
165
+
166
+ def method_source(signature, body, path)
167
+ ["def #{signature}", " # #{path}", " out = +\"\""] +
168
+ body.map { |l| " #{l}" } + [" out", "end"]
169
+ end
170
+
171
+ def wrap(klass, body)
172
+ *modules, name = klass.split("::")
173
+ depth = modules.size
174
+ lines = [""]
175
+ modules.each_with_index { |m, i| lines << "#{" " * i}module #{m}" }
176
+ lines << "#{" " * depth}class #{name}"
177
+ body.each { |l| lines << (l.empty? ? "" : "#{" " * (depth + 1)}#{l}") }
178
+ lines << "#{" " * depth}end"
179
+ modules.size.times { |i| lines << "#{" " * (modules.size - 1 - i)}end" }
180
+ lines
181
+ end
182
+
183
+ def controller_file!(dir, what)
184
+ return if File.exist?(File.join(@root, "app/controllers/#{dir}_controller.rb"))
185
+
186
+ raise Error, "#{what} has no controller: app/controllers/#{dir}_controller.rb does not exist"
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -2,11 +2,16 @@
2
2
 
3
3
  require "optparse"
4
4
  require "json"
5
+ require_relative "build/app_config"
6
+ require_relative "build/app_files"
7
+ require_relative "build/view_compiler"
5
8
 
6
9
  module Edgeway
7
10
  module CLI
8
11
  # edgeway build: app/, config/ -> build/main.rb -> build/app.wasm, and the
9
- # gem's js/ -> build/edgeway/.
12
+ # gem's js/ -> build/edgeway/. Spinel compiles with --incremental (cached C
13
+ # units, fast rebuilds, no inlining across units) unless --release, which
14
+ # builds as one unit for deploying.
10
15
  #
11
16
  # Spinel resolves require at compile time and has no autoloading, so
12
17
  # build/main.rb requires every file, in an order where a class body finds
@@ -16,22 +21,26 @@ module Edgeway
16
21
  # 2. app/*/ other than models/ and controllers/ (services, ...)
17
22
  # 3. app/models/
18
23
  # 4. app/controllers/
19
- # 5. config/routes.rb
24
+ # 5. build/views.rb, the Ruby compiled from app/views (when there is one)
25
+ # 6. config/routes.rb
20
26
  # Within each, application_*.rb come first (the base classes), then the
21
27
  # rest by path (byte order).
22
28
  class Build
23
29
  def self.run(args)
24
30
  env = "development"
31
+ release = false
25
32
  OptionParser.new do |o|
26
- o.banner = "Usage: edgeway build [--env ENV]"
33
+ o.banner = "Usage: edgeway build [--env ENV] [--release]"
27
34
  o.on("--env ENV", "environment (default: development)") { |v| env = v }
35
+ o.on("--release", "build as one unit, without --incremental (for deploying)") { release = true }
28
36
  end.parse!(args)
29
- new(Dir.pwd, env).run
37
+ new(Dir.pwd, env, release:).run
30
38
  end
31
39
 
32
- def initialize(root, env)
40
+ def initialize(root, env, release: false)
33
41
  @root = root
34
42
  @env = env
43
+ @release = release
35
44
  end
36
45
 
37
46
  def run
@@ -55,18 +64,29 @@ module Edgeway
55
64
  lines << 'require "edgeway/service/cloudflare/jwt"'
56
65
  lines << ""
57
66
  lines << connection_line(config)
67
+ config.databases.each { |name, binding_name| lines << connect_line(config, name, binding_name) }
58
68
  lines << "Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new"
59
69
  lines << ""
60
- lines.concat(AppFiles.new(@root).require_lines)
70
+ views = ViewCompiler.new(@root).compile
71
+ File.write(File.join(@root, "build/views.rb"), views) if views
72
+ lines.concat(AppFiles.new(@root).require_lines(views: views && "views"))
61
73
  lines << ""
62
74
  lines << "Edgeway::Server.run"
63
75
  File.write(File.join(@root, "build/main.rb"), lines.join("\n") + "\n")
64
76
  end
65
77
 
66
78
  def connection_line(config)
79
+ "Edgeway::Model.connection = #{adapter_expression(config, config.binding_name)}"
80
+ end
81
+
82
+ def connect_line(config, name, binding_name)
83
+ "Edgeway::Model.connect(#{name.inspect}, #{adapter_expression(config, binding_name)})"
84
+ end
85
+
86
+ def adapter_expression(config, binding_name)
67
87
  case config.adapter
68
- when "d1" then %(Edgeway::Model.connection = Edgeway::Cloudflare::D1Adapter.new(binding: #{config.binding_name.inspect}))
69
- else "Edgeway::Model.connection = Edgeway::DB::MemoryAdapter.new"
88
+ when "d1" then "Edgeway::Cloudflare::D1Adapter.new(binding: #{binding_name.inspect})"
89
+ else "Edgeway::DB::MemoryAdapter.new"
70
90
  end
71
91
  end
72
92
 
@@ -74,14 +94,19 @@ module Edgeway
74
94
  dest = File.join(@root, "build/edgeway")
75
95
  FileUtils.mkdir_p(dest)
76
96
  Dir.glob(File.join(CLI.gem_dir, "js/*.js")).each { |f| FileUtils.cp(f, dest) }
77
- js = "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
78
- "export default #{JSON.generate({ "env" => config.env, "d1Binding" => config.binding_name })};\n"
79
- File.write(File.join(dest, "config.js"), js)
97
+ File.write(File.join(dest, "config.js"), config_js(config))
98
+ end
99
+
100
+ def config_js(config)
101
+ values = { "env" => config.env, "d1Binding" => config.binding_name, "sessions" => config.sessions }
102
+ "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
103
+ "export default #{JSON.generate(values)};\n"
80
104
  end
81
105
 
82
106
  def compile
83
107
  spinel = Toolchain.spinel || raise(Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)")
84
108
  cmd = [spinel, "-I", File.join(CLI.gem_dir, "lib"), "--target=wasm32-wasi", "build/main.rb", "-o", "build/app.wasm"]
109
+ cmd << "--incremental" unless @release
85
110
  puts cmd.join(" ")
86
111
  system(*cmd, chdir: @root) || raise(Error, "spinel failed")
87
112
  end
@@ -9,32 +9,37 @@ module Edgeway
9
9
  USAGE = <<~TEXT
10
10
  Usage: edgeway generate model NAME [field:type ...]
11
11
  edgeway generate controller NAME [action ...]
12
+ edgeway generate scaffold NAME [field:type ...]
12
13
 
13
14
  Types: #{%w[string text integer float boolean datetime].join(", ")} (default string)
14
15
  TEXT
15
16
 
16
- # field type => [the column's SQL type, its attribute type]
17
+ # field type => its column's SQL type, its attribute type, and the values
18
+ # (as Ruby source) the scaffold's test creates and updates with.
17
19
  # D1 is SQLite, which has no datetime/boolean type (only NULL, INTEGER,
18
20
  # REAL, TEXT and BLOB): a datetime is TEXT (ISO 8601, as Model.now
19
21
  # writes it) and a boolean is an INTEGER 0/1. It is SQLite's, not a
20
22
  # Cloudflare, limit.
23
+ Type = Struct.new(:sql, :attr, :created, :updated)
21
24
  TYPES = {
22
- "string" => ["TEXT", ":string"],
23
- "text" => ["TEXT", ":string"],
24
- "integer" => ["INTEGER", ":integer"],
25
- "float" => ["REAL", ":float"],
26
- "boolean" => ["INTEGER NOT NULL DEFAULT 0", ":boolean"],
27
- "datetime" => ["TEXT", ":datetime"]
25
+ "string" => Type.new("TEXT", ":string", '"MyString"', '"Updated"'),
26
+ "text" => Type.new("TEXT", ":string", '"MyText"', '"Updated"'),
27
+ "integer" => Type.new("INTEGER", ":integer", "1", "2"),
28
+ "float" => Type.new("REAL", ":float", "1.5", "2.5"),
29
+ "boolean" => Type.new("INTEGER NOT NULL DEFAULT 0", ":boolean", "true", "false"),
30
+ "datetime" => Type.new("TEXT", ":datetime", '"2026-01-01T00:00:00Z"', '"2026-02-01T00:00:00Z"')
28
31
  }.freeze
29
32
 
33
+ Field = Struct.new(:name, :sql, :attr, :created, :updated)
34
+
30
35
  RESOURCE_ACTIONS = %w[index show create update destroy].freeze
31
36
 
32
37
  def self.run(args)
33
38
  kind, name, *rest = args
34
- raise Error, USAGE unless %w[model controller].include?(kind) && name && !name.start_with?("-")
39
+ raise Error, USAGE unless %w[model controller scaffold].include?(kind) && name && !name.start_with?("-")
35
40
 
36
41
  generator = new(Dir.pwd)
37
- kind == "model" ? generator.model(name, rest) : generator.controller(name, rest)
42
+ generator.public_send(kind, name, rest)
38
43
  end
39
44
 
40
45
  def initialize(root)
@@ -46,21 +51,32 @@ module Edgeway
46
51
 
47
52
  def model(name, fields)
48
53
  base = underscore(name)
49
- table = Inflector.pluralize(base)
50
54
  columns = fields.map { |f| parse_field(f) }
51
- write("app/models/#{base}.rb", model_source(camelize(base), columns))
52
- write("db/migrations/#{next_migration_number}_create_#{table}.sql", migration_source(table, columns))
53
- write("test/models/#{base}_test.rb", model_test_source(camelize(base)))
54
- puts "\nApply the migration locally: #{PackageManager.exec} wrangler d1 migrations apply DB_NAME --local"
55
+ write_all(model_files(base, columns))
56
+ puts_migration_hint
55
57
  end
56
58
 
57
59
  def controller(name, actions)
58
60
  base = underscore(name).delete_suffix("_controller")
59
61
  actions.each { |a| raise Error, "invalid action name: #{a}" unless a.match?(/\A[a-z_][a-z0-9_]*\z/) }
60
- write("app/controllers/#{base}_controller.rb", controller_source(camelize(base), actions))
61
- write("test/controllers/#{base}_test.rb", controller_test_source(camelize(base)))
62
- puts "\nAdd to config/routes.rb:"
63
- route_hints(base, actions).each { |l| puts " #{l}" }
62
+ write_all([
63
+ ["app/controllers/#{base}_controller.rb", controller_source(camelize(base), actions)],
64
+ ["test/controllers/#{base}_test.rb", controller_test_source(camelize(base))]
65
+ ])
66
+ puts_route_hints(route_hints(base, actions))
67
+ end
68
+
69
+ def scaffold(name, fields)
70
+ base = underscore(Inflector.singularize(name))
71
+ plural = Inflector.pluralize(base)
72
+ columns = fields.map { |f| parse_field(f) }
73
+ # Every path is checked before the first is written: no half a scaffold.
74
+ write_all(model_files(base, columns) + [
75
+ ["app/controllers/#{plural}_controller.rb", scaffold_controller_source(camelize(base), plural, columns)],
76
+ ["test/controllers/#{plural}_test.rb", scaffold_test_source(camelize(base), plural, columns)]
77
+ ])
78
+ add_route(plural)
79
+ puts_migration_hint
64
80
  end
65
81
 
66
82
  private
@@ -71,21 +87,60 @@ module Edgeway
71
87
  name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
72
88
  end
73
89
 
74
- def camelize(name) = name.split("_").map(&:capitalize).join
90
+ def camelize(name) = Inflector.camelize(name)
75
91
 
76
- # "title:string" => ["title", "TEXT", ":string"]
92
+ # "title:string" => Field.new("title", "TEXT", ":string", '"MyString"', '"Updated"')
77
93
  def parse_field(spec)
78
94
  name, type = spec.split(":", 2)
79
95
  type ||= "string"
80
96
  raise Error, "invalid field name: #{spec}" unless name.match?(/\A[a-z_][a-z0-9_]*\z/)
81
97
  raise Error, "unknown field type #{type} (#{TYPES.keys.join(", ")})" unless TYPES.key?(type)
82
98
 
83
- [name, *TYPES[type]]
99
+ Field.new(name, *TYPES[type].to_a)
100
+ end
101
+
102
+ def model_files(base, columns)
103
+ table = Inflector.pluralize(base)
104
+ existing = Dir.glob("db/migrations/*_create_#{table}.sql", base: @root).first
105
+ raise Error, "#{existing} already exists" if existing
106
+
107
+ [
108
+ ["app/models/#{base}.rb", model_source(camelize(base), columns)],
109
+ ["db/migrations/#{next_migration_number}_create_#{table}.sql", migration_source(table, columns)],
110
+ ["test/models/#{base}_test.rb", model_test_source(camelize(base))]
111
+ ]
112
+ end
113
+
114
+ def puts_migration_hint
115
+ puts "\nApply the migration locally: #{PackageManager.exec} wrangler d1 migrations apply DB_NAME --local"
116
+ end
117
+
118
+ def add_route(plural)
119
+ path = File.join(@root, "config/routes.rb")
120
+ text = File.read(path)
121
+ match = text.match(/^([ \t]*)[\w.]*routes\.draw[ \t]+do[ \t]*\|[ \t]*(\w+)[ \t]*\|[ \t]*(\r?\n)/)
122
+ return puts_route_hints(["r.resources :#{plural}"], "no `routes.draw do |r|` block found") unless match
123
+
124
+ indent, var, eol = match.captures
125
+ line = "#{var}.resources :#{plural}"
126
+ return puts " identical #{line}" if text.match?(/^[ \t]*#{Regexp.escape(var)}\.resources[ \t]*\(?[ \t]*:#{plural}(?!\w)/)
127
+
128
+ close = text.index(/^#{Regexp.escape(indent)}end\b/, match.end(0))
129
+ return puts_route_hints([line], "no closing `end` found") unless close
130
+
131
+ File.write(path, text[0...close] + "#{indent} #{line}#{eol}" + text[close..])
132
+ puts " route #{line}"
133
+ end
134
+
135
+ def puts_route_hints(hints, reason = nil)
136
+ puts "\nconfig/routes.rb not changed: #{reason}" if reason
137
+ puts "\nAdd to config/routes.rb:"
138
+ hints.each { |l| puts " #{l}" }
84
139
  end
85
140
 
86
141
  def model_source(klass, columns)
87
- lines = columns.map do |name, _sql, attr|
88
- attr == ":boolean" ? " attribute :#{name}, :boolean, default: false" : " attribute :#{name}, #{attr}"
142
+ lines = columns.map do |c|
143
+ c.attr == ":boolean" ? " attribute :#{c.name}, :boolean, default: false" : " attribute :#{c.name}, #{c.attr}"
89
144
  end
90
145
  lines << " timestamps"
91
146
  "class #{klass} < ApplicationModel\n#{lines.join("\n")}\nend\n"
@@ -93,7 +148,7 @@ module Edgeway
93
148
 
94
149
  def migration_source(table, columns)
95
150
  lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
96
- columns.each { |name, sql, _attr| lines << " #{name} #{sql}" }
151
+ columns.each { |c| lines << " #{c.name} #{c.sql}" }
97
152
  lines << " created_at TEXT" << " updated_at TEXT"
98
153
  "CREATE TABLE #{table} (\n#{lines.join(",\n")}\n);\n"
99
154
  end
@@ -130,6 +185,96 @@ module Edgeway
130
185
  RUBY
131
186
  end
132
187
 
188
+ def scaffold_controller_source(klass, plural, columns)
189
+ var = underscore(klass)
190
+ lines = [
191
+ "class #{camelize(plural)}Controller < ApplicationController",
192
+ " def index = render json: #{klass}.order(:id).limit(50)",
193
+ "",
194
+ " def show = render json: #{klass}.find(params[:id])",
195
+ "",
196
+ " def create",
197
+ " #{var} = #{columns.empty? ? "#{klass}.create!" : "#{klass}.create!(#{var}_params)"}",
198
+ " render json: #{var}, status: :created",
199
+ " end",
200
+ "",
201
+ " def update",
202
+ " #{var} = #{klass}.find(params[:id])"
203
+ ]
204
+ lines << " #{var}.update!(#{var}_params)" unless columns.empty?
205
+ lines += [
206
+ " render json: #{var}",
207
+ " end",
208
+ "",
209
+ " def destroy",
210
+ " #{klass}.find(params[:id]).destroy",
211
+ " head :no_content",
212
+ " end"
213
+ ]
214
+ unless columns.empty?
215
+ permitted = columns.map { |c| ":#{c.name}" }.join(", ")
216
+ lines += ["", " private", "", " def #{var}_params = params.require(:#{var}).permit(#{permitted})"]
217
+ end
218
+ lines << "end"
219
+ lines.join("\n") + "\n"
220
+ end
221
+
222
+ def scaffold_test_source(klass, plural, columns)
223
+ var = underscore(klass)
224
+ create_args = columns.empty? ? "" : "(#{columns.map { |c| "#{c.name}: #{c.created}" }.join(", ")})"
225
+ hash = lambda do |value|
226
+ columns.empty? ? "{}" : "{ #{columns.map { |c| "\"#{c.name}\" => #{c.public_send(value)}" }.join(", ")} }"
227
+ end
228
+ checks = ->(value) { columns.map { |c| " assert_equal #{c.public_send(value)}, json[\"#{c.name}\"]" }.join("\n") }
229
+ <<~RUBY
230
+ require "edgeway/test"
231
+ require "test_helper"
232
+
233
+ class #{camelize(plural)}Test < Edgeway::IntegrationTest
234
+ def test_index
235
+ #{var} = #{klass}.create!#{create_args}
236
+ get "/#{plural}"
237
+ assert_response 200
238
+ assert_equal 1, json.size
239
+ assert_equal #{var}.id, json[0]["id"]
240
+ end
241
+
242
+ def test_show
243
+ #{var} = #{klass}.create!#{create_args}
244
+ get "/#{plural}/\#{#{var}.id}"
245
+ assert_response 200
246
+ assert_equal #{var}.id, json["id"]
247
+ end
248
+
249
+ def test_show_not_found
250
+ get "/#{plural}/0"
251
+ assert_response 404
252
+ end
253
+
254
+ def test_create
255
+ post "/#{plural}", json: { "#{var}" => #{hash.call(:created)} }
256
+ assert_response 201
257
+ assert_equal 1, #{klass}.count
258
+ #{checks.call(:created)}
259
+ end
260
+
261
+ def test_update
262
+ #{var} = #{klass}.create!#{create_args}
263
+ patch "/#{plural}/\#{#{var}.id}", json: { "#{var}" => #{hash.call(:updated)} }
264
+ assert_response 200
265
+ #{checks.call(:updated)}
266
+ end
267
+
268
+ def test_destroy
269
+ #{var} = #{klass}.create!#{create_args}
270
+ delete "/#{plural}/\#{#{var}.id}"
271
+ assert_response 204
272
+ assert_equal 0, #{klass}.count
273
+ end
274
+ end
275
+ RUBY
276
+ end
277
+
133
278
  def route_hints(base, actions)
134
279
  return ["r.resources :#{base}"] if actions.empty?
135
280
 
@@ -147,10 +292,13 @@ module Edgeway
147
292
  format("%04d", last + 1)
148
293
  end
149
294
 
295
+ def write_all(files)
296
+ files.each { |rel, _text| raise Error, "#{rel} already exists" if File.exist?(File.join(@root, rel)) }
297
+ files.each { |rel, text| write(rel, text) }
298
+ end
299
+
150
300
  def write(rel, text)
151
301
  dest = File.join(@root, rel)
152
- raise Error, "#{rel} already exists" if File.exist?(dest)
153
-
154
302
  FileUtils.mkdir_p(File.dirname(dest))
155
303
  File.write(dest, text)
156
304
  puts " create #{rel}"
@@ -36,7 +36,7 @@ module Edgeway
36
36
  FileUtils.chmod(0o755, dest) if File.executable?(src)
37
37
  puts " create #{dest}"
38
38
  end
39
- puts "\nNext: cd #{@dir} && bin/setup && bundle exec edgeway test && bin/build"
39
+ puts "\nNext: cd #{@dir} && bin/setup && bin/test && bin/build"
40
40
  end
41
41
  end
42
42
  end
@@ -24,8 +24,11 @@ module Edgeway
24
24
  # Where the migrations are read from at test time (the binary may run
25
25
  # from another directory); Edgeway::UnitTest#setup applies them.
26
26
  lines << "module Edgeway; TEST_APP_ROOT = #{@root.dump}; end"
27
+ lines << "module Edgeway; TEST_DATABASES = #{Build::DatabaseConfig.new(@root).names.inspect}; end"
27
28
  lines << ""
28
- lines.concat(AppFiles.new(@root).require_lines)
29
+ views = Build::ViewCompiler.new(@root).compile
30
+ File.write(File.join(@root, "tmp/views.rb"), views) if views
31
+ lines.concat(Build::AppFiles.new(@root).require_lines(views: views && "views"))
29
32
  File.write(File.join(@root, "tmp/test_app.rb"), lines.join("\n") + "\n")
30
33
  end
31
34
  end
data/lib/edgeway/cli.rb CHANGED
@@ -5,9 +5,7 @@
5
5
  require "fileutils"
6
6
  require "yaml"
7
7
  require_relative "version"
8
- require_relative "cli/app_config"
9
8
  require_relative "cli/toolchain"
10
- require_relative "cli/app_files"
11
9
  require_relative "cli/build"
12
10
  require_relative "cli/precheck"
13
11
  require_relative "cli/test"
@@ -22,9 +20,12 @@ module Edgeway
22
20
  Usage: edgeway COMMAND [options]
23
21
 
24
22
  new APP create a new app in APP/
25
- build [--env ENV] compile the app to build/app.wasm (ENV: development)
23
+ build [--env ENV] [--release]
24
+ compile the app to build/app.wasm (ENV: development);
25
+ --release builds for deploying (no --incremental)
26
26
  test [ARGS] regenerate tmp/test_app.rb, run test/ with spinel_test-unit
27
- generate, g KIND NAME generate a model (NAME field:type ...) or a controller (NAME action ...)
27
+ generate, g KIND NAME generate a model (NAME field:type ...), a controller (NAME action ...)
28
+ or a scaffold (NAME field:type ...)
28
29
  routes print the routes
29
30
  version print the version
30
31
  TEXT
@@ -8,7 +8,8 @@ module Edgeway
8
8
 
9
9
  NON_ACTIONS = ["render", "head", "headers", "params", "request", "performed?", "to_response",
10
10
  "action?", "run_before_actions", "rescue_with_handler",
11
- "authenticate!", "current_claims", "authenticated?"]
11
+ "authenticate!", "current_claims", "authenticated?",
12
+ "action_name", "action_name=", "raw"]
12
13
 
13
14
  def self.before_action(name, only: [], except: [])
14
15
  @own_before_actions ||= []
@@ -48,9 +49,11 @@ module Edgeway
48
49
  end
49
50
 
50
51
  attr_reader :request
52
+ attr_accessor :action_name
51
53
 
52
54
  def initialize(request)
53
55
  @request = request
56
+ @action_name = ""
54
57
  @status = 200
55
58
  @headers = {}
56
59
  @response_body = ""
@@ -63,9 +66,16 @@ module Edgeway
63
66
  def headers = @headers
64
67
 
65
68
  def action?(name)
66
- self.respond_to?(name.to_sym) && !NON_ACTIONS.include?(name)
69
+ return false if name.start_with?("_view_") || name.start_with?("_render_")
70
+ respond_to?(name.to_sym) && !NON_ACTIONS.include?(name)
67
71
  end
68
72
 
73
+ def raw(text) = View::SafeHTML.new(text)
74
+
75
+ def _render_view(action) = nil
76
+
77
+ def _render_layout(content) = content
78
+
69
79
  def params
70
80
  @params ||= wrapped_params
71
81
  end