edgeway 0.1.0 → 0.3.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+
6
+ module Edgeway
7
+ module CLI
8
+ class CloudflareTest < Test
9
+ RUNTIME_DIR = "tmp/edgeway"
10
+ RUNNER = "cloudflare-test-runner.js"
11
+ MIN_NODE_MAJOR = 24
12
+
13
+ # The apps whose test environment uses the d1 adapter.
14
+ def self.handles?(database)
15
+ database.section?("test") && database.section("test")["adapter"] == "d1"
16
+ end
17
+
18
+ def initialize(root)
19
+ super
20
+ @config = Build::AppConfig.load(root, "test")
21
+ end
22
+
23
+ def run(args)
24
+ check_prerequisites
25
+ write_test_app
26
+ write_runtime
27
+ require_spinel_test_unit
28
+ argv = ["-I", @root, "-I", CLI.gem_dir + "/lib", *args]
29
+ exit WasmTestCLI.new(argv, root: @root, launcher: launcher).run
30
+ end
31
+
32
+ private
33
+
34
+ def database_lines
35
+ [
36
+ 'require "edgeway/db/memory"',
37
+ 'require "edgeway/service/cloudflare/test_databases"',
38
+ 'require "edgeway/service/cloudflare/jwt"',
39
+ 'require "edgeway/service/fetch"',
40
+ "module Edgeway; TEST_DATABASES = #{@config.databases.keys.inspect}; end",
41
+ "module Edgeway; TEST_BINDINGS = #{bindings.inspect}; end"
42
+ ]
43
+ end
44
+
45
+ def bindings
46
+ { "primary" => @config.binding_name }.merge(@config.databases)
47
+ end
48
+
49
+ def migration_directories
50
+ { "primary" => "db/migrations" }.merge(@config.databases.to_h { |name, _binding| [name, "db/#{name}/migrations"] })
51
+ end
52
+
53
+ def write_runtime
54
+ databases = bindings.map do |name, binding_name|
55
+ { "binding" => binding_name, "migrations" => File.join(@root, migration_directories.fetch(name)) }
56
+ end
57
+ values = {
58
+ "env" => "test", "d1Binding" => @config.binding_name, "sessions" => @config.sessions,
59
+ "appRoot" => @root, "databases" => databases
60
+ }
61
+ directory = File.join(@root, RUNTIME_DIR)
62
+ Build::JsFiles.copy(directory, Build::JsFiles.config_js(values, "test"), tests: true)
63
+ File.write(File.join(directory, "package.json"), %({"type": "module"}\n))
64
+ end
65
+
66
+ def launcher
67
+ ["node", *node_flags, File.join(@root, RUNTIME_DIR, RUNNER)]
68
+ end
69
+
70
+ def node_flags
71
+ has_jspi = system("node", "-e", "process.exit(typeof WebAssembly.Suspending === 'function' ? 0 : 1)")
72
+ has_jspi ? [] : ["--experimental-wasm-jspi", "--experimental-wasm-exnref"]
73
+ end
74
+
75
+ def require_spinel_test_unit
76
+ require "spinel_test/unit/cli"
77
+ require_relative "cloudflare_test/wasm_test_cli"
78
+ rescue LoadError
79
+ raise Error, 'spinel_test-unit is not installed (add gem "spinel_test-unit", "~> 0.1", group: :test to the Gemfile and run bundle install)'
80
+ end
81
+
82
+ def check_prerequisites
83
+ check_node
84
+ raise Error, "no wrangler.toml in #{@root} (the tests run on the local D1 it describes)" unless File.exist?(File.join(@root, "wrangler.toml"))
85
+
86
+ check_wrangler
87
+ raise Error, "no WASI SDK found (install it with mise, or set WASI_SDK=/path/to/wasi-sdk)" unless Toolchain.wasi_sdk
88
+ raise Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)" unless Toolchain.spinel
89
+
90
+ check_migration_directories
91
+ end
92
+
93
+ def check_node
94
+ raise Error, "node is not installed; `edgeway test` runs the tests on the local D1 of wrangler, which needs Node.js #{MIN_NODE_MAJOR} or later" unless Toolchain.find_on_path("node")
95
+
96
+ version, = Open3.capture2("node", "-p", "process.versions.node")
97
+ return if version.to_i >= MIN_NODE_MAJOR
98
+
99
+ raise Error, "Node.js #{version.strip} is too old: `edgeway test` needs #{MIN_NODE_MAJOR} or later (WebAssembly JSPI)"
100
+ end
101
+
102
+ def check_wrangler
103
+ return if File.directory?(File.join(@root, "node_modules/wrangler"))
104
+
105
+ raise Error, "wrangler is not installed in #{@root}/node_modules; run #{PackageManager.install} (or bin/setup)"
106
+ end
107
+
108
+ def check_migration_directories
109
+ @config.databases.each_key do |name|
110
+ directory = migration_directories.fetch(name)
111
+ next if File.directory?(File.join(@root, directory))
112
+
113
+ raise Error, "database #{name} (config/database.yml) has no #{directory} directory"
114
+ end
115
+ end
116
+ end
117
+
118
+ Test::PLATFORMS << CloudflareTest
119
+ end
120
+ 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
@@ -3,31 +3,50 @@
3
3
  module Edgeway
4
4
  module CLI
5
5
  class Test
6
+ PLATFORMS = []
7
+
6
8
  def self.run(args)
7
9
  root = Dir.pwd
8
- Precheck.sqlite!
9
- new(root).write_test_app
10
- exec("spinel_test-unit", "-I", root, "-I", CLI.gem_dir + "/lib", *args)
10
+ database = Build::DatabaseConfig.new(root)
11
+ (PLATFORMS.find { |platform| platform.handles?(database) } || Test).new(root).run(args)
11
12
  end
12
13
 
13
14
  def initialize(root)
14
15
  @root = root
15
16
  end
16
17
 
18
+ def run(args)
19
+ Precheck.sqlite!
20
+ write_test_app
21
+ exec("spinel_test-unit", "-I", @root, "-I", CLI.gem_dir + "/lib", *args)
22
+ end
23
+
17
24
  def write_test_app
18
25
  raise Error, "no config/routes.rb in #{@root}" unless File.exist?(File.join(@root, "config/routes.rb"))
19
26
 
20
27
  FileUtils.mkdir_p(File.join(@root, "tmp"))
21
28
  lines = ["# Generated by `edgeway test` from app/ and config/; do not edit.", 'require "edgeway"']
22
- lines << 'require "edgeway/db/memory"'
23
- lines << 'require "edgeway/db/sqlite"'
24
- # Where the migrations are read from at test time (the binary may run
25
- # from another directory); Edgeway::UnitTest#setup applies them.
26
- lines << "module Edgeway; TEST_APP_ROOT = #{@root.dump}; end"
29
+ lines.concat(database_lines)
27
30
  lines << ""
28
- lines.concat(AppFiles.new(@root).require_lines)
31
+ views = Build::ViewCompiler.new(@root).compile
32
+ File.write(File.join(@root, "tmp/views.rb"), views) if views
33
+ lines.concat(Build::AppFiles.new(@root).require_lines(views: views && "views"))
29
34
  File.write(File.join(@root, "tmp/test_app.rb"), lines.join("\n") + "\n")
30
35
  end
36
+
37
+ private
38
+
39
+ def database_lines
40
+ [
41
+ 'require "edgeway/db/memory"',
42
+ 'require "edgeway/db/sqlite"',
43
+ 'require "edgeway/test/sqlite_databases"',
44
+ # Where the migrations are read from at test time (the binary may run
45
+ # from another directory); Edgeway::TestDatabases applies them.
46
+ "module Edgeway; TEST_APP_ROOT = #{@root.dump}; end",
47
+ "module Edgeway; TEST_DATABASES = #{Build::DatabaseConfig.new(@root).names.inspect}; end"
48
+ ]
49
+ end
31
50
  end
32
51
  end
33
52
  end
@@ -19,6 +19,11 @@ module Edgeway
19
19
  find_on_path("spinel") || mise_which("spinel")
20
20
  end
21
21
 
22
+ def self.wasi_sdk
23
+ dir = [ENV["WASI_SDK"], ENV["WASI_SDK_PATH"]].find { |value| !value.to_s.empty? } || "/opt/wasi-sdk"
24
+ File.directory?(dir) ? dir : nil
25
+ end
26
+
22
27
  def self.find_on_path(name)
23
28
  ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, name) }
24
29
  .find { |f| File.file?(f) && File.executable?(f) }
data/lib/edgeway/cli.rb CHANGED
@@ -5,12 +5,11 @@
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"
12
+ require_relative "cli/cloudflare_test"
14
13
  require_relative "cli/routes"
15
14
  require_relative "cli/package_manager"
16
15
  require_relative "cli/generate"
@@ -22,9 +21,13 @@ module Edgeway
22
21
  Usage: edgeway COMMAND [options]
23
22
 
24
23
  new APP create a new app in APP/
25
- build [--env ENV] compile the app to build/app.wasm (ENV: development)
24
+ build [--env ENV] [--release]
25
+ compile the app to build/app.wasm (ENV: development);
26
+ --release builds for deploying (no --incremental)
26
27
  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 ...)
28
+ (natively, or as wasm on the platform's local database, by database.yml's test:)
29
+ generate, g KIND NAME generate a model (NAME field:type ...), a controller (NAME action ...)
30
+ or a scaffold (NAME field:type ...)
28
31
  routes print the routes
29
32
  version print the version
30
33
  TEXT
@@ -5,15 +5,35 @@ module Edgeway
5
5
  include Rescuable
6
6
  include Rendering
7
7
  include Authentication
8
+ include Caching
9
+ include RateLimiting
8
10
 
9
11
  NON_ACTIONS = ["render", "head", "headers", "params", "request", "performed?", "to_response",
10
12
  "action?", "run_before_actions", "rescue_with_handler",
11
- "authenticate!", "current_claims", "authenticated?"]
13
+ "authenticate!", "current_claims", "authenticated?",
14
+ "action_name", "action_name=", "raw",
15
+ "expires_in", "no_store", "fresh_when", "stale?"]
12
16
 
13
17
  def self.before_action(name, only: [], except: [])
18
+ add_before_action(Callback.new(name, only.map(&:to_s), except.map(&:to_s)))
19
+ end
20
+
21
+ def self.rate_limit(binding_name, by: nil, name: nil, retry_after: nil, only: [], except: [])
22
+ add_before_action(RateLimiting::Rule.build(binding_name, by, name, retry_after, only, except))
23
+ (@own_non_actions ||= []) << by.to_s unless by.nil?
24
+ end
25
+
26
+ def self.own_non_actions = (@own_non_actions || [])
27
+
28
+ def self.non_action?(name)
29
+ lineage.any? { |c| c.own_non_actions.include?(name) }
30
+ end
31
+
32
+ def self.add_before_action(callback)
14
33
  @own_before_actions ||= []
15
- @own_before_actions << Callback.new(name, only.map(&:to_s), except.map(&:to_s))
34
+ @own_before_actions << callback
16
35
  end
36
+ private_class_method :add_before_action
17
37
 
18
38
  def self.own_before_actions = (@own_before_actions || [])
19
39
 
@@ -48,9 +68,11 @@ module Edgeway
48
68
  end
49
69
 
50
70
  attr_reader :request
71
+ attr_accessor :action_name
51
72
 
52
73
  def initialize(request)
53
74
  @request = request
75
+ @action_name = ""
54
76
  @status = 200
55
77
  @headers = {}
56
78
  @response_body = ""
@@ -63,9 +85,16 @@ module Edgeway
63
85
  def headers = @headers
64
86
 
65
87
  def action?(name)
66
- self.respond_to?(name.to_sym) && !NON_ACTIONS.include?(name)
88
+ return false if name.start_with?("_view_") || name.start_with?("_render_")
89
+ respond_to?(name.to_sym) && !NON_ACTIONS.include?(name) && !self.class.non_action?(name)
67
90
  end
68
91
 
92
+ def raw(text) = View::SafeHTML.new(text)
93
+
94
+ def _render_view(action) = nil
95
+
96
+ def _render_layout(content) = content
97
+
69
98
  def params
70
99
  @params ||= wrapped_params
71
100
  end