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
@@ -15,6 +15,7 @@ module Edgeway
15
15
  klass = controller_class(route)
16
16
  raise RoutingError, "no controller for #{route.controller_path}" if klass.nil?
17
17
  controller = klass.new(request)
18
+ controller.action_name = route.action
18
19
  # A route whose action isn't defined (or is one of Base's own public
19
20
  # methods) is a 404, as in Rails.
20
21
  raise RoutingError, "no route for #{request.method} #{request.path}" unless controller.action?(route.action)
@@ -26,7 +27,7 @@ module Edgeway
26
27
  # a route can't reach a private method.
27
28
  action = route.action.to_sym
28
29
  controller.public_send(action) if controller.run_before_actions(route.action)
29
- controller.head(:no_content) unless controller.performed?
30
+ implicit_render(controller, route) unless controller.performed?
30
31
  rescue => e
31
32
  raise e unless controller.rescue_with_handler(e)
32
33
  # A handler that rendered nothing answers 204, as an action does.
@@ -34,6 +35,15 @@ module Edgeway
34
35
  end
35
36
  controller.to_response
36
37
  end
38
+
39
+ def self.implicit_render(controller, route)
40
+ content = controller._render_view(route.action)
41
+ if content.nil?
42
+ controller.head(:no_content)
43
+ else
44
+ controller.render(html: View::SafeHTML.new(content))
45
+ end
46
+ end
37
47
  end
38
48
  end
39
49
  end
@@ -10,6 +10,8 @@ module Edgeway
10
10
  "service_unavailable" => 503
11
11
  }
12
12
 
13
+ UNSET = Object.new
14
+
13
15
  def self.json_response(status, json_text)
14
16
  HTTP::Response.json(status, json_text)
15
17
  end
@@ -39,10 +41,28 @@ module Edgeway
39
41
  end
40
42
  end
41
43
 
42
- def render(json:, status: 200)
43
- @status = Rendering.status_code(status)
44
- @headers["Content-Type"] = "application/json"
45
- @response_body = JSON.generate(Rendering.json_value(json)) + "\n"
44
+ def self.html_content(value)
45
+ value.is_a?(View::SafeHTML) ? value.to_s : View.escape(value)
46
+ end
47
+
48
+ def render(template = nil, json: UNSET, html: UNSET, status: 200, layout: true)
49
+ code = Rendering.status_code(status)
50
+ if !json.equal?(UNSET)
51
+ body = JSON.generate(Rendering.json_value(json)) + "\n"
52
+ @headers["Content-Type"] = "application/json"
53
+ else
54
+ content = html.equal?(UNSET) ? nil : Rendering.html_content(html)
55
+ if content.nil?
56
+ name = template.nil? ? @action_name : template.to_s
57
+ content = _render_view(name)
58
+ raise MissingTemplate, "no template #{name.inspect} for #{self.class.name}" if content.nil?
59
+ end
60
+ body = layout ? _render_layout(content) : content
61
+ @headers["Content-Type"] = "text/html; charset=utf-8"
62
+ @headers["X-Content-Type-Options"] = "nosniff"
63
+ end
64
+ @status = code
65
+ @response_body = body
46
66
  @performed = true
47
67
  end
48
68
 
@@ -1,6 +1,18 @@
1
+ require_relative "query_stats"
2
+
1
3
  module Edgeway
2
4
  module DB
3
5
  class Adapter
6
+ MAX_BINDS = 100
7
+
8
+ def query_stats
9
+ @query_stats ||= QueryStats.new
10
+ end
11
+
12
+ def record_meta(meta)
13
+ query_stats.add(meta)
14
+ end
15
+
4
16
  def select_all(sql, binds)
5
17
  raise Error, "#{self.class} does not define select_all"
6
18
  end
@@ -9,6 +21,10 @@ module Edgeway
9
21
  raise Error, "#{self.class} does not define execute"
10
22
  end
11
23
 
24
+ def exec(sql)
25
+ raise Error, "#{self.class} does not define exec"
26
+ end
27
+
12
28
  def batch(statements)
13
29
  raise Error, "#{self.class} does not define batch"
14
30
  end
@@ -19,11 +35,33 @@ module Edgeway
19
35
  rows[0]
20
36
  end
21
37
 
38
+ def last_insert_id_of(value)
39
+ return 0 if value.nil?
40
+ number = value.to_i
41
+ number > Edgeway::Model::INT_MAX || number < Edgeway::Model::INT_MIN ? value.to_s : number
42
+ rescue RangeError
43
+ value.to_s
44
+ end
45
+
46
+ def check_binds(binds)
47
+ return if binds.size <= MAX_BINDS
48
+ raise ArgumentError, "too many bind parameters: #{binds.size} (at most #{MAX_BINDS})"
49
+ end
50
+
22
51
  def fail_with(message)
23
52
  raise RecordNotUnique, message if message.include?("UNIQUE constraint failed")
53
+ raise NotNullViolation, message if message.include?("NOT NULL constraint failed")
54
+ raise InvalidForeignKey, message if message.include?("FOREIGN KEY constraint failed")
55
+ raise CheckViolation, message if message.include?("CHECK constraint failed")
24
56
  raise DatabaseError, message
25
57
  end
26
58
 
59
+ def raise_error_result(res, prefix = "")
60
+ message = "#{prefix}#{res["error"]}"
61
+ raise DatabaseUnavailable, message if res.key?("unavailable")
62
+ fail_with(message)
63
+ end
64
+
27
65
  def supports_returning? = true
28
66
  def placeholder(n) = "?"
29
67
  def quote_boolean(value) = value ? 1 : 0
@@ -32,8 +32,8 @@ module Edgeway
32
32
  end
33
33
 
34
34
  # Makes the next query or execute whose SQL contains `containing` (any,
35
- # by default) fail with this database message, after logging it, as D1
36
- # would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
35
+ # by default) fail with this database message, after logging it, as the
36
+ # database would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
37
37
  def fail_next(message, containing = "")
38
38
  @failure = message
39
39
  @failure_on = containing
@@ -42,18 +42,25 @@ module Edgeway
42
42
  def supports_returning? = @returning
43
43
 
44
44
  def select_all(sql, binds)
45
+ check_binds(binds)
45
46
  record(sql, binds)
46
47
  @queue.empty? ? [] : @queue.shift
47
48
  end
48
49
 
49
50
  def execute(sql, binds)
51
+ check_binds(binds)
50
52
  record(sql, binds)
51
53
  id = @next_id
52
54
  @next_id += 1
53
55
  { "changes" => @changes, "last_insert_id" => id }
54
56
  end
55
57
 
58
+ def exec(sql)
59
+ record(sql, [])
60
+ end
61
+
56
62
  def batch(statements)
63
+ statements.each { |s| check_binds(s[1]) }
57
64
  @log << "BATCH #{statements.size}"
58
65
  statements.map { |s| execute(s[0], s[1]) }
59
66
  end
@@ -0,0 +1,46 @@
1
+ module Edgeway
2
+ module DB
3
+ # What the database reported for one request.
4
+ class QueryStats
5
+ MAX_COUNT = 2147483647
6
+
7
+ attr_reader :queries, :rows_read, :rows_written, :duration_ms, :served_by_region, :served_by_primary
8
+
9
+ def initialize
10
+ @queries = 0
11
+ @rows_read = 0
12
+ @rows_written = 0
13
+ @duration_ms = 0.0
14
+ @served_by_region = ""
15
+ @served_by_primary = true
16
+ end
17
+
18
+ def add(meta)
19
+ rows_read = capped_sum(@rows_read, meta["rows_read"])
20
+ rows_written = capped_sum(@rows_written, meta["rows_written"])
21
+ duration_ms = @duration_ms + meta["duration"].to_f
22
+ @rows_read = rows_read
23
+ @rows_written = rows_written
24
+ @duration_ms = duration_ms
25
+ @served_by_region = meta["served_by_region"].to_s if meta.key?("served_by_region")
26
+ @served_by_primary = meta["served_by_primary"] != false if meta.key?("served_by_primary")
27
+ @queries = capped_sum(@queries, 1)
28
+ end
29
+
30
+ def log_text
31
+ "db=#{@queries} rows_read=#{count_text(@rows_read)} rows_written=#{count_text(@rows_written)} db_ms=#{format("%.2f", @duration_ms)}"
32
+ end
33
+
34
+ private
35
+
36
+ def count_text(count)
37
+ count == MAX_COUNT ? "#{count}+" : count.to_s
38
+ end
39
+
40
+ def capped_sum(total, count)
41
+ return MAX_COUNT if count.to_i > MAX_COUNT - total
42
+ total + count.to_i
43
+ end
44
+ end
45
+ end
46
+ end
@@ -20,6 +20,7 @@ module Edgeway
20
20
  ffi_func :sqlite3_prepare_v2, [:ptr, :str, :int, :ptr, :ptr], :int
21
21
  ffi_func :sqlite3_bind_text, [:ptr, :int, :str, :int, :ptr], :int
22
22
  ffi_func :sqlite3_bind_int64, [:ptr, :int, :long], :int
23
+ ffi_func :sqlite3_bind_blob, [:ptr, :int, :str, :int, :ptr], :int
23
24
  ffi_func :sqlite3_bind_double, [:ptr, :int, :double], :int
24
25
  ffi_func :sqlite3_bind_null, [:ptr, :int], :int
25
26
  ffi_func :sqlite3_step, [:ptr], :int
@@ -35,6 +36,35 @@ module Edgeway
35
36
  ffi_func :sqlite3_changes, [:ptr], :int
36
37
  ffi_func :sqlite3_libversion_number, [], :int
37
38
 
39
+ # A BLOB can hold NUL, which would cut a :str result short, so it is read
40
+ # as hex text.
41
+ ffi_source <<~C
42
+ #include <stdlib.h>
43
+
44
+ extern const void *sqlite3_column_blob(void *statement, int column);
45
+ extern int sqlite3_column_bytes(void *statement, int column);
46
+
47
+ static char *hex;
48
+ static int hex_capacity;
49
+
50
+ const char *column_hex(void *statement, int column) {
51
+ const unsigned char *bytes = sqlite3_column_blob(statement, column);
52
+ int length = sqlite3_column_bytes(statement, column);
53
+ if (2 * length + 1 > hex_capacity) {
54
+ hex = realloc(hex, 2 * length + 1);
55
+ if (!hex) abort();
56
+ hex_capacity = 2 * length + 1;
57
+ }
58
+ for (int i = 0; i < length; i++) {
59
+ hex[2 * i] = "0123456789abcdef"[bytes[i] >> 4];
60
+ hex[2 * i + 1] = "0123456789abcdef"[bytes[i] & 15];
61
+ }
62
+ hex[2 * length] = 0;
63
+ return hex;
64
+ }
65
+ C
66
+ ffi_func :column_hex, [:ptr, :int], :str
67
+
38
68
  ffi_buffer :db_out, 8
39
69
  ffi_buffer :stmt_out, 8
40
70
  ffi_read_ptr :read_ptr, 0
@@ -43,6 +73,7 @@ module Edgeway
43
73
  class SQLiteAdapter < Adapter
44
74
  INTEGER = 1
45
75
  FLOAT = 2
76
+ BLOB = 4
46
77
  NULL = 5
47
78
 
48
79
  attr_reader :log
@@ -63,6 +94,7 @@ module Edgeway
63
94
  def supports_returning? = @returning
64
95
 
65
96
  def select_all(sql, binds)
97
+ check_binds(binds)
66
98
  stmt = start(sql, binds)
67
99
  rows = []
68
100
  rc = SQLiteFFI.sqlite3_step(stmt)
@@ -75,26 +107,25 @@ module Edgeway
75
107
  end
76
108
 
77
109
  def execute(sql, binds)
110
+ check_binds(binds)
78
111
  stmt = start(sql, binds)
79
112
  rc = SQLiteFFI.sqlite3_step(stmt)
80
113
  while rc == SQLiteFFI::ROW
81
114
  rc = SQLiteFFI.sqlite3_step(stmt)
82
115
  end
83
116
  finish(stmt, rc)
84
- { "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => SQLiteFFI.sqlite3_last_insert_rowid(@db) }
117
+ { "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => last_insert_id_of(SQLiteFFI.sqlite3_last_insert_rowid(@db)) }
118
+ end
119
+
120
+ def exec(sql)
121
+ @log << "#{sql} | []"
122
+ in_transaction { sql.split("\n").each { |line| run_script(line) unless line.strip.empty? } }
85
123
  end
86
124
 
87
125
  def batch(statements)
88
126
  @log << "BATCH #{statements.size}"
89
- run_script("BEGIN")
90
127
  results = []
91
- begin
92
- statements.each { |s| results << execute(s[0], s[1]) }
93
- rescue => e
94
- run_script("ROLLBACK")
95
- raise e
96
- end
97
- run_script("COMMIT")
128
+ in_transaction { statements.each { |s| results << execute(s[0], s[1]) } }
98
129
  results
99
130
  end
100
131
 
@@ -113,6 +144,17 @@ module Edgeway
113
144
 
114
145
  private
115
146
 
147
+ def in_transaction
148
+ run_script("BEGIN")
149
+ begin
150
+ yield
151
+ rescue => e
152
+ run_script("ROLLBACK")
153
+ raise e
154
+ end
155
+ run_script("COMMIT")
156
+ end
157
+
116
158
  def run_script(sql_text)
117
159
  rc = SQLiteFFI.sqlite3_exec(@db, sql_text, nil, nil, nil)
118
160
  fail_with(SQLiteFFI.sqlite3_errmsg(@db)) if rc != SQLiteFFI::OK
@@ -122,6 +164,7 @@ module Edgeway
122
164
  # finalized before the error is raised.
123
165
  def start(sql, binds)
124
166
  @log << "#{sql} | #{JSON.generate(binds)}"
167
+ check_blob_binds(binds)
125
168
  rc = SQLiteFFI.sqlite3_prepare_v2(@db, sql, -1, SQLiteFFI.stmt_out, nil)
126
169
  if rc != SQLiteFFI::OK
127
170
  message = SQLiteFFI.sqlite3_errmsg(@db)
@@ -148,6 +191,8 @@ module Edgeway
148
191
  SQLiteFFI.sqlite3_bind_int64(stmt, n, value)
149
192
  elsif value.is_a?(Float)
150
193
  SQLiteFFI.sqlite3_bind_double(stmt, n, value)
194
+ elsif value.is_a?(Hash)
195
+ bind_blob(stmt, n, value)
151
196
  elsif value == true
152
197
  SQLiteFFI.sqlite3_bind_int64(stmt, n, 1)
153
198
  elsif value == false
@@ -158,6 +203,19 @@ module Edgeway
158
203
  end
159
204
  end
160
205
 
206
+ def check_blob_binds(binds)
207
+ binds.each do |value|
208
+ next unless value.is_a?(Hash)
209
+ bytes = value.key?("$blob") ? Edgeway::Model.cast(:binary, value) : nil
210
+ raise ArgumentError, "a Hash bind must be {\"$blob\" => base64}: #{value}" if bytes.nil?
211
+ end
212
+ end
213
+
214
+ def bind_blob(statement, position, value)
215
+ bytes = Edgeway::Model.cast(:binary, value).to_s
216
+ SQLiteFFI.sqlite3_bind_blob(statement, position, bytes, bytes.bytesize, -1)
217
+ end
218
+
161
219
  # Finalizes the statement; raises if its last step failed.
162
220
  def finish(stmt, rc)
163
221
  message = rc == SQLiteFFI::DONE ? "" : SQLiteFFI.sqlite3_errmsg(@db)
@@ -165,6 +223,10 @@ module Edgeway
165
223
  fail_with(message) if rc != SQLiteFFI::DONE
166
224
  end
167
225
 
226
+ def read_blob(statement, column)
227
+ Edgeway::Model.blob([SQLiteFFI.column_hex(statement, column)].pack("H*"))
228
+ end
229
+
168
230
  def read_row(stmt)
169
231
  row = {}
170
232
  n = SQLiteFFI.sqlite3_column_count(stmt)
@@ -178,6 +240,8 @@ module Edgeway
178
240
  row[name] = SQLiteFFI.sqlite3_column_int64(stmt, i)
179
241
  elsif type == FLOAT
180
242
  row[name] = SQLiteFFI.sqlite3_column_double(stmt, i)
243
+ elsif type == BLOB
244
+ row[name] = read_blob(stmt, i)
181
245
  else
182
246
  row[name] = SQLiteFFI.sqlite3_column_text(stmt, i)
183
247
  end
@@ -41,6 +41,10 @@ module Edgeway
41
41
  # details only in the log.
42
42
  class InvalidHeader < Error; end
43
43
 
44
+ # render named a template that was not compiled from app/views: 500, with
45
+ # the name only in the log, since it is a bug in the app.
46
+ class MissingTemplate < Error; end
47
+
44
48
  # A failure in the database adapter: 500.
45
49
  class DatabaseError < Error; end
46
50
 
@@ -49,6 +53,24 @@ module Edgeway
49
53
  # the log only.
50
54
  class RecordNotUnique < DatabaseError; end
51
55
 
56
+ # A write the database refused for a NOT NULL constraint: 500, since it is a
57
+ # bug in the app. The message goes to the log only.
58
+ class NotNullViolation < DatabaseError; end
59
+
60
+ # A write the database refused for a FOREIGN KEY constraint: 500, since it is
61
+ # a bug in the app. The message goes to the log only.
62
+ class InvalidForeignKey < DatabaseError; end
63
+
64
+ # A write the database refused for a CHECK constraint: 500, since it is a bug
65
+ # in the app. The message goes to the log only.
66
+ class CheckViolation < DatabaseError; end
67
+
68
+ # A transient database failure or overload (a lost connection, a reset object...):
69
+ # 503 with "Retry-After: 1" and a fixed body. The message goes to the log
70
+ # only. Edgeway never retries it. A write that fails this way may still have
71
+ # been applied, so only an idempotent request is safe to retry.
72
+ class DatabaseUnavailable < DatabaseError; end
73
+
52
74
  # A record that failed its validations: 422 with {"errors": [...]}.
53
75
  class RecordInvalid < Error
54
76
  attr_reader :errors
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ pull_request:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+ # The first run builds Spinel from source; later runs use mise-action's cache.
17
+ timeout-minutes: 15
18
+
19
+ steps:
20
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
21
+ with:
22
+ persist-credentials: false
23
+ # `edgeway test` links libsqlite3.
24
+ - name: Install libsqlite3-dev
25
+ run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
26
+ # The Ruby in .ruby-version.
27
+ - name: Set up Ruby
28
+ uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
29
+ with:
30
+ bundler-cache: true
31
+ # Everything in mise.toml: the WASI SDK (bin/build needs it), Spinel,
32
+ # Node.js, and <%= PackageManager.name %>.
33
+ - name: Install the tools
34
+ uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
35
+ - name: Run the tests
36
+ run: bin/test
37
+ - name: Build
38
+ run: bin/build --release
@@ -0,0 +1 @@
1
+ <%= RUBY_VERSION %>
@@ -9,14 +9,18 @@ bin/setup # mise, bundle, <%= PackageManager
9
9
  mise install # Spinel and the WASI SDK (mise.toml)
10
10
  bundle install
11
11
  <%= PackageManager.install %>
12
- bundle exec edgeway build # build/app.wasm (or bin/build)
12
+ bundle exec edgeway build # build/app.wasm (or bin/build); --incremental, for development
13
13
  <%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local
14
14
  <%= PackageManager.exec %> wrangler dev # http://localhost:8787/health
15
15
  bundle exec edgeway routes
16
16
  ```
17
17
 
18
+ `bin/deploy` builds with `--release`: one unit, without Spinel's `--incremental` (cached C units, no inlining across units), for deploying.
19
+
18
20
  Without mise, set `SPINEL` to the Spinel compiler and `WASI_SDK` to the WASI SDK.
19
21
 
20
- Tests (`test/`) run with `bundle exec edgeway test [ARGS]`. Always use it rather than `spinel_test-unit` directly: it rewrites `tmp/test_app.rb` (git-ignored) from `app/` on every run, so new files are picked up. Each test gets a fresh in-memory SQLite database with `db/migrations/*.sql` applied, so running the tests requires SQLite (sqlite3): its development library has to be installed to link (`apt install libsqlite3-dev` on Debian/Ubuntu, `dnf install sqlite-devel` on Fedora; macOS has it).
22
+ Tests (`test/`) run with `bin/test [FILE...]` (all of `test/` when no file is given; it runs `bundle exec edgeway test`). Always use it rather than `spinel_test-unit` directly: it rewrites `tmp/test_app.rb` (git-ignored) from `app/` on every run, so new files are picked up. Each test gets a fresh in-memory SQLite database with `db/migrations/*.sql` applied, so running the tests requires SQLite (sqlite3): its development library has to be installed to link (`apt install libsqlite3-dev` on Debian/Ubuntu, `dnf install sqlite-devel` on Fedora; macOS has it).
23
+
24
+ `.github/workflows/ci.yml` runs `bin/test` and `bin/build --release` on GitHub Actions for pushes to main and pull requests.
21
25
 
22
26
  Model tests inherit `Edgeway::UnitTest` (a database, no requests), request tests `Edgeway::IntegrationTest` (a `UnitTest` that also has `get`, `post`, `json`, `assert_response`).
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>App</title>
7
+ </head>
8
+ <body>
9
+ <%= yield %>
10
+ </body>
11
+ </html>
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Compiles the app to build/app.wasm, like `bundle exec edgeway build`.
3
- # Arguments go to it (--env ENV).
3
+ # Arguments go to it (--env ENV, --release).
4
4
  set -euo pipefail
5
5
  cd "$(dirname "$0")/.."
6
6
 
@@ -5,6 +5,6 @@
5
5
  set -euo pipefail
6
6
  cd "$(dirname "$0")/.."
7
7
 
8
- bin/build
8
+ bin/build --release
9
9
  <%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --remote
10
10
  <%= PackageManager.exec %> wrangler deploy "$@"
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Runs the tests, like `bundle exec edgeway test`.
3
+ # Arguments go to it: test files to run (all of test/ when none are given).
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec edgeway test "$@"
@@ -2,6 +2,23 @@
2
2
  # the wasm. adapter: d1 uses the D1 binding named by binding:, which must
3
3
  # match a binding in wrangler.toml (wrangler.toml says which D1 database it
4
4
  # is); memory is for tests.
5
+ #
6
+ # Another database goes under databases:, which a model picks with
7
+ # `self.database = :analytics` (see the README's "Multiple databases").
8
+ # It has its own binding, migrations (db/<name>/migrations) and test database.
9
+ # production:
10
+ # adapter: d1
11
+ # binding: DB
12
+ # databases:
13
+ # analytics:
14
+ # binding: ANALYTICS
15
+ #
16
+ # For a database with read replication, `sessions: true` next to its binding
17
+ # (see the README's "Read replication (Sessions)").
18
+ # production:
19
+ # adapter: d1
20
+ # binding: DB
21
+ # sessions: true
5
22
  development:
6
23
  adapter: d1
7
24
  binding: DB
@@ -14,7 +14,7 @@ spinel = "https://github.com/y-yagi/mise-spinel"
14
14
  # master, so an install (and CI's cache of it) is the Spinel that was tested.
15
15
  # depends: the wasi-sdk is installed first, so the build also makes Spinel's
16
16
  # wasm32-wasi runtime (mise-spinel does when WASI_SDK, set below, is there).
17
- spinel = { version = "530ba2b17", depends = ["github:WebAssembly/wasi-sdk"] }
17
+ spinel = { version = "ec0ad13e4", depends = ["github:WebAssembly/wasi-sdk"] }
18
18
 
19
19
  # wrangler (package.json) runs on Node.js.
20
20
  node = "lts"
@@ -21,3 +21,10 @@ binding = "DB"
21
21
  database_name = "<%= @name %>-db"
22
22
  database_id = "00000000-0000-0000-0000-000000000000"
23
23
  migrations_dir = "db/migrations"
24
+
25
+ # A second database (config/database.yml's databases:) is another binding:
26
+ # [[d1_databases]]
27
+ # binding = "ANALYTICS"
28
+ # database_name = "<%= @name %>-analytics"
29
+ # database_id = "00000000-0000-0000-0000-000000000000"
30
+ # migrations_dir = "db/analytics/migrations"
@@ -12,9 +12,7 @@ module Edgeway
12
12
  end
13
13
 
14
14
  def reject(token, message)
15
- # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
16
- # Spinel refuses this String as it is, or emits C that doesn't compile.
17
- @answers[token] = JSON.generate({ "error" => message.dup })
15
+ @answers[token] = JSON.generate({ "error" => message })
18
16
  end
19
17
 
20
18
  def unavailable(token, message)
@@ -63,12 +63,12 @@ module Edgeway
63
63
  add_body_params(decode_pairs(@body, "form body"))
64
64
  end
65
65
 
66
- # decode_www_form never raises on bad bytes: it turns those that aren't
66
+ # URI.decode_www_form never raises on bad bytes: it turns those that aren't
67
67
  # UTF-8 (%ff) into U+FFFD. That is only right when the client sent a
68
68
  # U+FFFD itself, so otherwise one in the result is a BadRequest.
69
69
  def decode_pairs(text, what)
70
70
  raise BadRequest, "#{what} is not valid UTF-8" unless text.valid_encoding?
71
- pairs = decode_www_form(text)
71
+ pairs = URI.decode_www_form(text)
72
72
  sent = text.include?(REPLACEMENT) || text.downcase.include?("%ef%bf%bd")
73
73
  unless sent
74
74
  pairs.each do |k, v|
@@ -78,26 +78,6 @@ module Edgeway
78
78
  pairs
79
79
  end
80
80
 
81
- # URI.decode_www_form, written out so that Edgeway doesn't require
82
- # "uri": a send with a computed name (Dispatcher) reaches uri's
83
- # find_proxy(env = ENV), which Spinel refuses since its Strings are
84
- # shared by reference (matz/spinel 44119ab30). Same answers as uri's,
85
- # ArgumentError for a non-ASCII input included.
86
- def decode_www_form(text)
87
- raise ArgumentError, "the input of URI.decode_www_form must be ASCII only string" unless text.ascii_only?
88
- return [] if text.empty?
89
- pieces = text.split("&", -1)
90
- pieces.pop if pieces.last.empty?
91
- pieces.map do |piece|
92
- key, _sep, val = piece.partition("=")
93
- [unescape_www(key), unescape_www(val)]
94
- end
95
- end
96
-
97
- def unescape_www(s)
98
- s.b.gsub(/\+|%\h\h/) { |m| m == "+" ? " " : m[1, 2].hex.chr }.force_encoding(Encoding::UTF_8).scrub
99
- end
100
-
101
81
  def read_json
102
82
  json = JSONBody.new(@body)
103
83
  @json_body = json.value
@@ -1,8 +1,21 @@
1
1
  module Edgeway
2
2
  module Inflector
3
+ # "admin_posts" -> "AdminPosts"
4
+ def self.camelize(name) = name.split("_").map(&:capitalize).join
5
+
6
+ # "admin/posts" -> "Admin::PostsController"
7
+ def self.controller_class_name(path)
8
+ path.split("/").map { |part| camelize(part) }.join("::") + "Controller"
9
+ end
10
+
3
11
  def self.singularize(name)
4
12
  n = name.to_s
5
13
  return n[0...-3] + "y" if n.end_with?("ies")
14
+ if n.end_with?("es")
15
+ stem = n[0...-2]
16
+ return stem if sibilant?(stem)
17
+ end
18
+ return n if n.end_with?("ss") || n.end_with?("us")
6
19
  return n[0...-1] if n.end_with?("s")
7
20
  n
8
21
  end
@@ -10,7 +23,13 @@ module Edgeway
10
23
  def self.pluralize(name)
11
24
  n = name.to_s
12
25
  return n[0...-1] + "ies" if n.end_with?("y") && n.length > 1 && !"aeiou".include?(n[-2])
26
+ return n + "es" if n.end_with?("s") || n.end_with?("x") || n.end_with?("sh") || n.end_with?("ch")
13
27
  n + "s"
14
28
  end
29
+
30
+ def self.sibilant?(stem)
31
+ stem.end_with?("ss") || (stem.end_with?("us") && !stem.end_with?("ous")) || stem.end_with?("x") ||
32
+ stem.end_with?("sh") || stem.end_with?("ch")
33
+ end
15
34
  end
16
35
  end