edgeway 0.1.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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +444 -0
  4. data/exe/edgeway +6 -0
  5. data/js/cgi.js +157 -0
  6. data/js/cloudflare.js +103 -0
  7. data/js/cors.js +87 -0
  8. data/js/jwt.js +279 -0
  9. data/js/runtime.js +99 -0
  10. data/js/wasi-shim.js +191 -0
  11. data/lib/edgeway/application.rb +70 -0
  12. data/lib/edgeway/cli/app_config.rb +35 -0
  13. data/lib/edgeway/cli/app_files.rb +37 -0
  14. data/lib/edgeway/cli/build.rb +90 -0
  15. data/lib/edgeway/cli/generate.rb +160 -0
  16. data/lib/edgeway/cli/new.rb +43 -0
  17. data/lib/edgeway/cli/package_manager.rb +21 -0
  18. data/lib/edgeway/cli/precheck.rb +45 -0
  19. data/lib/edgeway/cli/routes.rb +22 -0
  20. data/lib/edgeway/cli/test.rb +33 -0
  21. data/lib/edgeway/cli/toolchain.rb +35 -0
  22. data/lib/edgeway/cli.rb +59 -0
  23. data/lib/edgeway/config.rb +9 -0
  24. data/lib/edgeway/controller/authentication.rb +42 -0
  25. data/lib/edgeway/controller/base.rb +95 -0
  26. data/lib/edgeway/controller/callbacks.rb +29 -0
  27. data/lib/edgeway/controller/dispatcher.rb +39 -0
  28. data/lib/edgeway/controller/rendering.rb +65 -0
  29. data/lib/edgeway/controller/rescuable.rb +26 -0
  30. data/lib/edgeway/db/adapter.rb +32 -0
  31. data/lib/edgeway/db/memory.rb +73 -0
  32. data/lib/edgeway/db/sqlite.rb +190 -0
  33. data/lib/edgeway/errors.rb +61 -0
  34. data/lib/edgeway/generators/app/templates/.gitignore +6 -0
  35. data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
  36. data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
  37. data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
  38. data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
  39. data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
  40. data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
  41. data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
  42. data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
  43. data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
  44. data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
  45. data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
  46. data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
  47. data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
  48. data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
  49. data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
  50. data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
  51. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
  52. data/lib/edgeway/http/auth/jwt.rb +127 -0
  53. data/lib/edgeway/http/auth/stub.rb +29 -0
  54. data/lib/edgeway/http/json_body.rb +37 -0
  55. data/lib/edgeway/http/parameters.rb +42 -0
  56. data/lib/edgeway/http/request.rb +115 -0
  57. data/lib/edgeway/http/response.rb +46 -0
  58. data/lib/edgeway/inflector.rb +16 -0
  59. data/lib/edgeway/logger.rb +21 -0
  60. data/lib/edgeway/model/attributes.rb +85 -0
  61. data/lib/edgeway/model/base.rb +173 -0
  62. data/lib/edgeway/model/macros.rb +60 -0
  63. data/lib/edgeway/model/persistence.rb +154 -0
  64. data/lib/edgeway/model/relation.rb +295 -0
  65. data/lib/edgeway/model/validations.rb +136 -0
  66. data/lib/edgeway/routing/mapper.rb +57 -0
  67. data/lib/edgeway/routing/route.rb +39 -0
  68. data/lib/edgeway/routing/route_set.rb +27 -0
  69. data/lib/edgeway/server.rb +12 -0
  70. data/lib/edgeway/service/cloudflare/d1.rb +101 -0
  71. data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
  72. data/lib/edgeway/test.rb +97 -0
  73. data/lib/edgeway/version.rb +5 -0
  74. data/lib/edgeway.rb +30 -0
  75. metadata +115 -0
@@ -0,0 +1,154 @@
1
+ module Edgeway
2
+ module Model
3
+ # A has_many: the association's name, its foreign key and primary key (nil
4
+ # for the defaults) and its dependent mode (nil, "destroy" or "delete_all").
5
+ class HasMany
6
+ attr_reader :name, :foreign_key, :primary_key, :mode
7
+
8
+ def initialize(name, foreign_key, primary_key, mode)
9
+ @name = name
10
+ @foreign_key = foreign_key
11
+ @primary_key = primary_key
12
+ @mode = mode
13
+ end
14
+ end
15
+
16
+ # A belongs_to: the association's name, our foreign key column and the
17
+ # parent's primary key column.
18
+ class BelongsTo
19
+ attr_reader :name, :foreign_key, :primary_key
20
+
21
+ def initialize(name, foreign_key, primary_key)
22
+ @name = name
23
+ @foreign_key = foreign_key
24
+ @primary_key = primary_key
25
+ end
26
+ end
27
+
28
+ module Persistence
29
+ def save
30
+ return false unless valid?
31
+ touch(!@persisted)
32
+ if @persisted
33
+ update_row
34
+ else
35
+ insert_row
36
+ end
37
+ true
38
+ end
39
+
40
+ def save!
41
+ raise RecordInvalid.new(@errors) unless save
42
+ true
43
+ end
44
+
45
+ def update(attrs)
46
+ assign(attrs)
47
+ save
48
+ end
49
+
50
+ def update!(attrs)
51
+ assign(attrs)
52
+ save!
53
+ end
54
+
55
+ def destroy
56
+ if self.class.dependents.empty?
57
+ delete_row
58
+ else
59
+ Model.batch do
60
+ self.class.dependents.each do |d|
61
+ if d.mode == "destroy"
62
+ association(d.name).destroy_all
63
+ else
64
+ association(d.name).delete_all
65
+ end
66
+ end
67
+ delete_row
68
+ end
69
+ end
70
+ @persisted = false
71
+ true
72
+ end
73
+
74
+ # eager_load keeps what it loaded here: association and parent_of answer
75
+ # from it without a query.
76
+ def cache_children(name, relation)
77
+ @loaded_children[name] = relation
78
+ end
79
+
80
+ def cache_parent(name, record)
81
+ @loaded_parents[name] = record
82
+ end
83
+
84
+ def association(name)
85
+ return @loaded_children[name] if @loaded_children.key?(name)
86
+ cfg = nil
87
+ self.class.has_manys.each { |h| cfg = h if h.name == name }
88
+ raise ArgumentError, "no has_many #{name}" if cfg.nil?
89
+ # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
90
+ # Spinel refuses this String as it is, or emits C that doesn't compile.
91
+ fk = cfg.foreign_key.nil? ? "#{Edgeway::Model.underscore(self.class.name.split("::").last)}_id" : cfg.foreign_key.dup
92
+ pk = self.class.column(cfg.primary_key.nil? ? "id" : cfg.primary_key.dup)
93
+ Edgeway::Model::Base.model_named(name, self.class.name).where(fk => @attributes[pk])
94
+ end
95
+
96
+ def parent_of(name)
97
+ return @loaded_parents[name] if @loaded_parents.key?(name)
98
+ cfg = nil
99
+ self.class.belongs_to_list.each { |b| cfg = b if b.name == name }
100
+ raise ArgumentError, "no belongs_to #{name}" if cfg.nil?
101
+ parent = Edgeway::Model::Base.model_named(name, self.class.name)
102
+ key = @attributes[cfg.foreign_key]
103
+ return parent.find(key) if cfg.primary_key == "id"
104
+ found = key.nil? ? nil : parent.find_by(cfg.primary_key => key)
105
+ raise RecordNotFound, "#{parent.table_name} #{cfg.primary_key} #{key} not found" if found.nil?
106
+ found
107
+ end
108
+
109
+ private
110
+
111
+ def delete_row
112
+ conn = Model.connection
113
+ Model.write("DELETE FROM #{self.class.table_name} WHERE id = #{conn.placeholder(1)}", [id])
114
+ end
115
+
116
+ def update_row
117
+ conn = Model.connection
118
+ cols = self.class.attribute_types.keys
119
+ values = cols.map { |c| Model.to_db(@attributes[c]) }
120
+ sets = cols.each_with_index.map { |c, i| "#{c} = #{conn.placeholder(i + 1)}" }.join(", ")
121
+ Model.write("UPDATE #{self.class.table_name} SET #{sets} WHERE id = #{conn.placeholder(cols.size + 1)}",
122
+ values + [id])
123
+ end
124
+
125
+ def insert_row
126
+ raise ArgumentError, "create isn't supported inside Model.batch" if Model.batching?
127
+ conn = Model.connection
128
+ table = self.class.table_name
129
+ cols = self.class.attribute_types.keys.reject { |c| @attributes[c].nil? }
130
+ values = cols.map { |c| Model.to_db(@attributes[c]) }
131
+ sql = "INSERT INTO #{table}"
132
+ if cols.empty?
133
+ sql += " DEFAULT VALUES"
134
+ else
135
+ marks = cols.each_with_index.map { |_c, i| conn.placeholder(i + 1) }
136
+ sql += " (#{cols.join(", ")}) VALUES (#{marks.join(", ")})"
137
+ end
138
+ if conn.supports_returning?
139
+ load_row(conn.insert_returning(sql + " RETURNING #{self.class.columns.join(", ")}", values))
140
+ else
141
+ new_id = conn.execute(sql, values)["last_insert_id"]
142
+ load_row(conn.select_all("SELECT #{self.class.columns.join(", ")} FROM #{table} WHERE id = #{conn.placeholder(1)}", [new_id])[0])
143
+ end
144
+ end
145
+
146
+ def touch(creating)
147
+ types = self.class.attribute_types
148
+ now = Model.now
149
+ write_attribute("created_at", now) if creating && types.key?("created_at")
150
+ write_attribute("updated_at", now) if types.key?("updated_at")
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,295 @@
1
+ module Edgeway
2
+ module Model
3
+ class Relation
4
+ # eager is the associations eager_load names (empty: none); records, when
5
+ # set, are what to_a answers instead of a query (an eager-loaded has_many).
6
+ attr_writer :records
7
+
8
+ def initialize(model, where_cols = [], binds = [], order = [], limit = nil, offset = nil, eager = [])
9
+ @model = model
10
+ @where_cols = where_cols
11
+ @binds = binds
12
+ @order = order
13
+ @limit = limit
14
+ @offset = offset
15
+ @eager = eager
16
+ @records = nil
17
+ end
18
+
19
+ def where(conditions)
20
+ cols = @where_cols.dup
21
+ binds = @binds.dup
22
+ conditions.each do |k, v|
23
+ col = @model.column(k)
24
+ casted = Model.cast(@model.type_of(col), v)
25
+ # An integer outside 32 bits can't be held as one; it goes as its
26
+ # text, which SQLite compares as the number it is, so nothing
27
+ # matches (a find on it is a 404).
28
+ casted = v.to_s if casted.nil? && !v.nil?
29
+ cols << col
30
+ binds << Model.to_db(casted)
31
+ end
32
+ Relation.new(@model, cols, binds, @order, @limit, @offset, @eager)
33
+ end
34
+
35
+ def order(col, dir = :asc)
36
+ Relation.new(@model, @where_cols, @binds, @order + [order_term(col, dir)], @limit, @offset, @eager)
37
+ end
38
+
39
+ # Replaces all the terms.
40
+ def reorder(col, dir = :asc)
41
+ Relation.new(@model, @where_cols, @binds, [order_term(col, dir)], @limit, @offset, @eager)
42
+ end
43
+
44
+ def limit(n)
45
+ Relation.new(@model, @where_cols, @binds, @order, count_arg("limit", n), @offset, @eager)
46
+ end
47
+
48
+ def offset(n)
49
+ Relation.new(@model, @where_cols, @binds, @order, @limit, count_arg("offset", n), @eager)
50
+ end
51
+
52
+ # Loads the has_manys and belongs_tos named of every record with the one
53
+ # query: a LEFT JOIN each, always (no separate preload query). The
54
+ # records' associations then answer from memory. Calls accumulate.
55
+ #
56
+ # Todo.eager_load(:comments, :tags).to_a.each { |t| t.comments.to_a }
57
+ def eager_load(*names)
58
+ eager = @eager.dup
59
+ names.each do |n|
60
+ eager << n.to_s unless eager.include?(n.to_s)
61
+ end
62
+ Relation.new(@model, @where_cols, @binds, @order, @limit, @offset, eager)
63
+ end
64
+
65
+ # Same conditions, but to_a answers these records.
66
+ def loaded(records)
67
+ relation = Relation.new(@model, @where_cols, @binds, @order, @limit, @offset)
68
+ relation.records = records
69
+ relation
70
+ end
71
+
72
+ def to_a
73
+ return @records unless @records.nil?
74
+ return eager_records unless @eager.empty?
75
+ sql, binds = select(@model.columns.join(", "), true)
76
+ Model.connection.select_all(sql, binds).map { |row| @model.instantiate(row) }
77
+ end
78
+
79
+ def count
80
+ sql, binds = select("1", false)
81
+ Model.connection.select_all("SELECT COUNT(*) AS n FROM (#{sql})", binds)[0]["n"].to_i
82
+ end
83
+
84
+ def exists? = limit(1).count > 0
85
+
86
+ def pluck(col)
87
+ name = @model.column(col)
88
+ type = @model.type_of(name)
89
+ sql, binds = select(name, true)
90
+ Model.connection.select_all(sql, binds).map { |row| Model.cast(type, row[name]) }
91
+ end
92
+
93
+ def first = (@order.empty? ? order(:id) : self).limit(1).to_a.first
94
+
95
+ def delete_all
96
+ raise ArgumentError, "delete_all doesn't support limit/offset" if @limit || @offset
97
+ conn = Model.connection
98
+ where_sql, _marks = where_clause(conn)
99
+ Model.write("DELETE FROM #{@model.table_name}#{where_sql}", @binds.compact)["changes"]
100
+ end
101
+
102
+ def destroy_all
103
+ records = to_a
104
+ records.each { |r| r.destroy }
105
+ records
106
+ end
107
+
108
+ def each(&block) = to_a.each(&block)
109
+ def map(&block) = to_a.map(&block)
110
+ def size = to_a.size
111
+ def empty? = !exists?
112
+
113
+ def find_by(conditions) = Relation.new(@model, @where_cols, @binds, @order, @limit, nil, @eager).where(conditions).limit(1).to_a.first
114
+
115
+ def find(id)
116
+ find_by("id" => id) || raise(RecordNotFound, "#{@model.table_name} #{id} not found")
117
+ end
118
+
119
+ def build(attrs = {})
120
+ record = @model.new(attrs)
121
+ @where_cols.each_with_index { |col, i| record.write_attribute(col, @binds[i]) }
122
+ record
123
+ end
124
+
125
+ def create(attrs = {})
126
+ record = build(attrs)
127
+ record.save
128
+ record
129
+ end
130
+
131
+ def create!(attrs = {})
132
+ record = build(attrs)
133
+ record.save!
134
+ record
135
+ end
136
+
137
+ def find_or_create_by(conditions) = find_or_create(conditions, false)
138
+ def find_or_create_by!(conditions) = find_or_create(conditions, true)
139
+
140
+ private
141
+
142
+ # The records with @eager loaded. The conditions, order and limit apply
143
+ # to our own records, so they run in a subquery that each association's
144
+ # table is joined to. A row is one of ours with one of each association's
145
+ # (nil without any); joining several has_manys repeats a child once per
146
+ # row of the others, so a record of ours comes once however many rows it
147
+ # has, and each child once per record (by id).
148
+ def eager_records
149
+ mine = @model.table_name
150
+ many_flags = []
151
+ joins = []
152
+ cols = @model.columns.map { |c| "#{mine}.#{c} AS p_#{c}" }
153
+ order = @order.empty? ? ["#{mine}.id"] : @order.map { |t| "#{mine}.#{t}" }
154
+ @eager.each_with_index do |name, i|
155
+ many_flags << eager_many?(name)
156
+ other = Edgeway::Model::Base.model_named(name, @model.name)
157
+ theirs = "eager_#{name}"
158
+ joins << " LEFT JOIN #{other.table_name} AS #{theirs} ON #{eager_on(name, other, theirs)}"
159
+ cols = cols + other.columns.map { |c| "#{theirs}.#{c} AS c#{i}_#{c}" }
160
+ order << "#{theirs}.id"
161
+ end
162
+ inner, binds = select(@model.columns.join(", "), true)
163
+ sql = "SELECT #{cols.join(", ")} FROM (#{inner}) AS #{mine}#{joins.join}"
164
+ sql += " ORDER BY #{order.join(", ")}"
165
+
166
+ records = []
167
+ # children[i][j]: the records of association j of record i
168
+ children = []
169
+ position = {}
170
+ seen = {}
171
+ Model.connection.select_all(sql, binds).each do |row|
172
+ id = row["p_id"]
173
+ i = position[id]
174
+ if i.nil?
175
+ i = records.size
176
+ position[id] = i
177
+ mine_row = {}
178
+ @model.columns.each { |c| mine_row[c] = row["p_" + c] }
179
+ records << @model.instantiate(mine_row)
180
+ lists = []
181
+ @eager.each { |_n| lists << [] }
182
+ children << lists
183
+ end
184
+ @eager.each_with_index do |name, j|
185
+ cid = row["c#{j}_id"]
186
+ next if cid.nil?
187
+ key = "#{j}:#{id}:#{cid}"
188
+ next if seen.key?(key)
189
+ seen[key] = true
190
+ other = Edgeway::Model::Base.model_named(name, @model.name)
191
+ their_row = {}
192
+ other.columns.each { |c| their_row[c] = row["c#{j}_" + c] }
193
+ children[i][j] << other.instantiate(their_row)
194
+ end
195
+ end
196
+ records.each_with_index do |record, i|
197
+ @eager.each_with_index do |name, j|
198
+ if many_flags[j]
199
+ record.cache_children(name, record.association(name).loaded(children[i][j]))
200
+ else
201
+ record.cache_parent(name, children[i][j][0]) unless children[i][j].empty?
202
+ end
203
+ end
204
+ end
205
+ records
206
+ end
207
+
208
+ def eager_many?(name)
209
+ many = nil
210
+ @model.has_manys.each { |h| many = h if h.name == name }
211
+ one = nil
212
+ @model.belongs_to_list.each { |b| one = b if b.name == name }
213
+ raise ArgumentError, "no association #{name}" if many.nil? && one.nil?
214
+ !many.nil?
215
+ end
216
+
217
+ # The join condition of the association `name` (the model `other`,
218
+ # joined as `theirs`) to our records.
219
+ def eager_on(name, other, theirs)
220
+ mine = @model.table_name
221
+ many = nil
222
+ @model.has_manys.each { |h| many = h if h.name == name }
223
+ if many.nil?
224
+ one = nil
225
+ @model.belongs_to_list.each { |b| one = b if b.name == name }
226
+ # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
227
+ # Spinel refuses this String as it is, or emits C that doesn't compile.
228
+ return "#{theirs}.#{other.column(one.primary_key.dup)} = #{mine}.#{@model.column(one.foreign_key.dup)}"
229
+ end
230
+ fk = many.foreign_key.nil? ? "#{Model.underscore(@model.name.split("::").last)}_id" : many.foreign_key
231
+ pk = many.primary_key.nil? ? "id" : many.primary_key
232
+ "#{theirs}.#{other.column(fk)} = #{mine}.#{@model.column(pk)}"
233
+ end
234
+
235
+ def find_or_create(conditions, bang)
236
+ found = find_by(conditions)
237
+ return found unless found.nil?
238
+ begin
239
+ bang ? create!(conditions) : create(conditions)
240
+ rescue Edgeway::RecordNotUnique => e
241
+ again = find_by(conditions)
242
+ raise e if again.nil?
243
+ again
244
+ end
245
+ end
246
+
247
+ def count_arg(what, n)
248
+ i = Model.to_int(n)
249
+ raise BadRequest, "bad #{what}: #{n}" if i.nil? || i < 0
250
+ i
251
+ end
252
+
253
+ def order_term(col, dir)
254
+ d = dir.to_s.downcase
255
+ raise BadRequest, "bad order direction: #{dir}" unless d == "asc" || d == "desc"
256
+ "#{@model.column(col)} #{d.upcase}"
257
+ end
258
+
259
+ # " WHERE ..." (empty without conditions) and the number of placeholders
260
+ # it used. A nil value is IS NULL, with no bind.
261
+ def where_clause(conn)
262
+ marks = 0
263
+ return ["", marks] if @where_cols.empty?
264
+ conds = @where_cols.each_with_index.map do |c, i|
265
+ if @binds[i].nil?
266
+ "#{c} IS NULL"
267
+ else
268
+ marks += 1
269
+ "#{c} = #{conn.placeholder(marks)}"
270
+ end
271
+ end
272
+ [" WHERE " + conds.join(" AND "), marks]
273
+ end
274
+
275
+ def select(what, ordered)
276
+ conn = Model.connection
277
+ where_sql, _marks = where_clause(conn)
278
+ sql = "SELECT #{what} FROM #{@model.table_name}#{where_sql}"
279
+ sql += " ORDER BY #{@order.join(", ")}" if ordered && !@order.empty?
280
+ binds = @binds.compact
281
+ if @limit
282
+ binds = binds + [@limit]
283
+ sql += " LIMIT #{conn.placeholder(binds.size)}"
284
+ elsif @offset
285
+ sql += " LIMIT -1"
286
+ end
287
+ if @offset
288
+ binds = binds + [@offset]
289
+ sql += " OFFSET #{conn.placeholder(binds.size)}"
290
+ end
291
+ [sql, binds]
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,136 @@
1
+ module Edgeway
2
+ module Model
3
+ module Validations
4
+ def errors = @errors
5
+
6
+ def valid?
7
+ @errors = []
8
+ @raw.each do |col, raw|
9
+ @errors << "#{col} is out of range" if !raw.nil? && @attributes[col].nil?
10
+ end
11
+
12
+ self.class.rules.each do |rule|
13
+ col = rule.column
14
+ value = rule.kind == "numericality" && @raw.key?(col) ? @raw[col] : @attributes[col]
15
+ msg = rule.message(value)
16
+ @errors << msg unless msg.nil?
17
+ end
18
+
19
+ if @errors.empty?
20
+ self.class.belongs_to_list.each do |b|
21
+ fk = @attributes[b.foreign_key]
22
+ @errors << "#{b.name} must exist" if fk.nil? || Base.model_named(b.name, self.class.name).find_by(b.primary_key => fk).nil?
23
+ end
24
+ end
25
+
26
+ if @errors.empty?
27
+ self.class.rules.each do |rule|
28
+ next unless rule.kind == "uniqueness"
29
+ @errors << "#{rule.column} has already been taken" if taken?(rule)
30
+ end
31
+ end
32
+ @errors.empty?
33
+ end
34
+
35
+ private
36
+
37
+ def taken?(rule)
38
+ value = @attributes[rule.column]
39
+ return false if value.nil?
40
+ found = self.class.where(rule.column => value)
41
+ rule.scope.each { |c| found = found.where(c => @attributes[self.class.column(c)]) }
42
+ other = found.limit(1).to_a.first
43
+ !other.nil? && (new_record? || other.id != id)
44
+ end
45
+ end
46
+
47
+ class Rule
48
+ attr_reader :column, :kind
49
+
50
+ def self.scope_columns(options)
51
+ return [] if options == true
52
+ scope = options[:scope]
53
+ return [] if scope.nil?
54
+ scope.is_a?(Array) ? scope.map(&:to_s) : [scope.to_s]
55
+ end
56
+
57
+ def self.bounds(options)
58
+ h = {}
59
+ return h if options == true
60
+ options.each do |k, v|
61
+ key = k.to_s
62
+ if key == "in"
63
+ h["minimum"] = v.min
64
+ h["maximum"] = v.max
65
+ else
66
+ h[key] = v == true ? 1 : v
67
+ end
68
+ end
69
+ h
70
+ end
71
+
72
+ def initialize(attribute, kind, bounds, list, pattern)
73
+ @column = attribute
74
+ @kind = kind
75
+ @bounds = bounds
76
+ @list = list
77
+ @pattern = pattern
78
+ end
79
+
80
+ def message(v)
81
+ # Uniqueness needs a query: Validations#taken? does it.
82
+ return nil if @kind == "uniqueness"
83
+ if @kind == "presence"
84
+ return "#{@column} can't be blank" if v.nil? || v.to_s.strip.empty?
85
+ return nil
86
+ end
87
+ return nil if v.nil?
88
+ case @kind
89
+ when "length" then length_message(v.to_s.length)
90
+ when "inclusion" then @list.include?(v.to_s) ? nil : "#{@column} is not included in the list"
91
+ when "format" then @pattern.match?(v.to_s) ? nil : "#{@column} is invalid"
92
+ when "numericality" then numericality_message(v)
93
+ end
94
+ end
95
+
96
+ def scope = @list
97
+
98
+ private
99
+
100
+ def length_message(n)
101
+ if @bounds.key?("minimum") && n < @bounds["minimum"]
102
+ return "#{@column} is too short (minimum is #{@bounds["minimum"]} characters)"
103
+ end
104
+ if @bounds.key?("maximum") && n > @bounds["maximum"]
105
+ return "#{@column} is too long (maximum is #{@bounds["maximum"]} characters)"
106
+ end
107
+ if @bounds.key?("is") && n != @bounds["is"]
108
+ return "#{@column} is the wrong length (should be #{@bounds["is"]} characters)"
109
+ end
110
+ nil
111
+ end
112
+
113
+ def numericality_message(v)
114
+ text = v.to_s
115
+ integer = text.match?(/\A[+-]?\d+\z/)
116
+ # Decimals and exponents too: Float#to_s writes 1.0e+20 and 1.0e-05.
117
+ return "#{@column} is not a number" unless integer || text.match?(/\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/)
118
+ return "#{@column} must be an integer" if @bounds.key?("only_integer") && !integer
119
+ n = text.to_f
120
+ if @bounds.key?("greater_than") && !(n > @bounds["greater_than"])
121
+ return "#{@column} must be greater than #{@bounds["greater_than"]}"
122
+ end
123
+ if @bounds.key?("greater_than_or_equal_to") && !(n >= @bounds["greater_than_or_equal_to"])
124
+ return "#{@column} must be greater than or equal to #{@bounds["greater_than_or_equal_to"]}"
125
+ end
126
+ if @bounds.key?("less_than") && !(n < @bounds["less_than"])
127
+ return "#{@column} must be less than #{@bounds["less_than"]}"
128
+ end
129
+ if @bounds.key?("less_than_or_equal_to") && !(n <= @bounds["less_than_or_equal_to"])
130
+ return "#{@column} must be less than or equal to #{@bounds["less_than_or_equal_to"]}"
131
+ end
132
+ nil
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,57 @@
1
+ module Edgeway
2
+ module Routing
3
+ class Mapper
4
+ ACTIONS = ["index", "show", "create", "update", "destroy"]
5
+
6
+ def initialize(routes, path_prefix, module_prefix)
7
+ @routes = routes
8
+ @path_prefix = path_prefix
9
+ @module_prefix = module_prefix
10
+ end
11
+
12
+ def get(path, to:) = add("GET", path, to)
13
+ def post(path, to:) = add("POST", path, to)
14
+ def patch(path, to:) = add("PATCH", path, to)
15
+ def put(path, to:) = add("PUT", path, to)
16
+ def delete(path, to:) = add("DELETE", path, to)
17
+
18
+ def root(to:) = add("GET", "/", to)
19
+
20
+ def namespace(name)
21
+ yield Mapper.new(@routes, "#{@path_prefix}/#{name}", "#{@module_prefix}#{name}/")
22
+ end
23
+
24
+ def resources(name, only: [], except: [])
25
+ base = "/#{name}"
26
+ member = "#{base}/:id"
27
+ (only + except).each do |a|
28
+ raise ArgumentError, "unknown action for resources :#{name}: #{a}" unless ACTIONS.include?(a.to_s)
29
+ end
30
+ want = ACTIONS.dup
31
+ want = only.map(&:to_s) unless only.empty?
32
+ want = want.reject { |a| except.map(&:to_s).include?(a) }
33
+
34
+ add("GET", base, "#{name}#index") if want.include?("index")
35
+ add("POST", base, "#{name}#create") if want.include?("create")
36
+ add("GET", member, "#{name}#show") if want.include?("show")
37
+ if want.include?("update")
38
+ add("PATCH", member, "#{name}#update")
39
+ add("PUT", member, "#{name}#update")
40
+ end
41
+ add("DELETE", member, "#{name}#destroy") if want.include?("destroy")
42
+ if block_given?
43
+ yield Mapper.new(@routes, "#{@path_prefix}#{base}/:#{Mapper.singular(name)}_id", @module_prefix)
44
+ end
45
+ end
46
+
47
+ def self.singular(name) = Inflector.singularize(name)
48
+
49
+ private
50
+
51
+ def add(verb, path, to)
52
+ controller, action = to.split("#", 2)
53
+ @routes.add(Route.new(verb, @path_prefix + path, @module_prefix + controller, action))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,39 @@
1
+ module Edgeway
2
+ module Routing
3
+ class Route
4
+ attr_reader :verb, :pattern, :controller_path, :action
5
+
6
+ def initialize(verb, pattern, controller_path, action)
7
+ @verb = verb
8
+ @pattern = pattern
9
+ @segments = Route.split(pattern)
10
+ @controller_path = controller_path
11
+ @action = action
12
+ end
13
+
14
+ def self.split(path) = path.split("/").reject(&:empty?)
15
+
16
+ def controller_class_name
17
+ parts = @controller_path.split("/").map do |part|
18
+ part.split("_").map(&:capitalize).join
19
+ end
20
+ parts.join("::") + "Controller"
21
+ end
22
+
23
+ def match(verb, path)
24
+ return nil unless verb == @verb || (verb == "HEAD" && @verb == "GET")
25
+ parts = Route.split(path)
26
+ return nil unless parts.size == @segments.size
27
+ found = {}
28
+ @segments.each_with_index do |seg, i|
29
+ if seg.start_with?(":")
30
+ found[seg[1..]] = parts[i]
31
+ elsif seg != parts[i]
32
+ return nil
33
+ end
34
+ end
35
+ found
36
+ end
37
+ end
38
+ end
39
+ end