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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +270 -9
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare.js +123 -22
- data/js/cors.js +15 -16
- data/js/headers.js +16 -0
- data/js/jwt.js +1 -1
- data/lib/edgeway/application.rb +6 -3
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/database_config.rb +92 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +36 -11
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +4 -1
- data/lib/edgeway/cli.rb +5 -4
- data/lib/edgeway/controller/base.rb +12 -2
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +46 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +22 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/request.rb +2 -22
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +39 -10
- data/lib/edgeway/test.rb +29 -8
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +3 -0
- metadata +17 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- 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
|
data/lib/edgeway/cli/build.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
69
|
-
else "Edgeway::
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
data/lib/edgeway/cli/generate.rb
CHANGED
|
@@ -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 =>
|
|
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" =>
|
|
23
|
-
"text" =>
|
|
24
|
-
"integer" =>
|
|
25
|
-
"float" =>
|
|
26
|
-
"boolean" =>
|
|
27
|
-
"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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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) =
|
|
90
|
+
def camelize(name) = Inflector.camelize(name)
|
|
75
91
|
|
|
76
|
-
# "title: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
|
-
|
|
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 |
|
|
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 { |
|
|
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}"
|
data/lib/edgeway/cli/new.rb
CHANGED
|
@@ -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 &&
|
|
39
|
+
puts "\nNext: cd #{@dir} && bin/setup && bin/test && bin/build"
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
data/lib/edgeway/cli/test.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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 ...)
|
|
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
|
-
|
|
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
|