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
|
@@ -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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
data/lib/edgeway/db/adapter.rb
CHANGED
|
@@ -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
|
data/lib/edgeway/db/memory.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/edgeway/db/sqlite.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/edgeway/errors.rb
CHANGED
|
@@ -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
|
|
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`).
|
|
@@ -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 = "
|
|
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
|
-
|
|
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)
|
data/lib/edgeway/http/request.rb
CHANGED
|
@@ -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
|
data/lib/edgeway/inflector.rb
CHANGED
|
@@ -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
|