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
data/js/wasi-shim.js ADDED
@@ -0,0 +1,191 @@
1
+ // A minimal WASI preview 1 shim: stdout/stderr, args, environment, clocks,
2
+ // random. No filesystem (no preopened directories); stdin serves the bytes
3
+ // given as the stdin option (a string or Uint8Array), then EOF.
4
+ // stdout/stderr go to onStdout/onStderr as decoded text, or, when
5
+ // onStdoutBytes/onStderrBytes is given, to it as raw bytes (binary output).
6
+ // Any import not implemented here answers ENOSYS.
7
+
8
+ const ESUCCESS = 0, EBADF = 8, ENOSYS = 52, ESPIPE = 70;
9
+
10
+ export class WasiExit extends Error {
11
+ constructor(code) {
12
+ super(`exit ${code}`);
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export class WASI {
18
+ // onStdout / onStderr receive decoded text chunks; onStdoutBytes /
19
+ // onStderrBytes, when given, receive Uint8Array chunks (copies) instead.
20
+ constructor({ args = ["main"], env = {}, stdin = new Uint8Array(0), onStdout = () => {}, onStderr = () => {},
21
+ onStdoutBytes = null, onStderrBytes = null } = {}) {
22
+ this.args = args;
23
+ this.stdin = typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin;
24
+ this.stdinPos = 0;
25
+ this.env = Object.entries(env).map(([k, v]) => `${k}=${v}`);
26
+ this.out = { 1: onStdout, 2: onStderr };
27
+ this.outBytes = { 1: onStdoutBytes, 2: onStderrBytes };
28
+ this.decoders = { 1: new TextDecoder(), 2: new TextDecoder() };
29
+ this.memory = null;
30
+ this.wasiImport = this.#imports();
31
+ }
32
+
33
+ // For a reactor module (no _start): hands the shim the memory and runs
34
+ // _initialize, if the module has one. The caller then calls the exports
35
+ // it wants; a proc_exit inside them throws WasiExit.
36
+ initialize(instance) {
37
+ this.memory = instance.exports.memory;
38
+ instance.exports._initialize?.();
39
+ }
40
+
41
+ // Runs the module's _start; answers the exit status.
42
+ start(instance) {
43
+ this.memory = instance.exports.memory;
44
+ try {
45
+ instance.exports._start();
46
+ return 0;
47
+ } catch (e) {
48
+ if (e instanceof WasiExit) return e.code;
49
+ throw e;
50
+ } finally {
51
+ for (const fd of [1, 2]) {
52
+ const rest = this.decoders[fd].decode();
53
+ if (rest) this.out[fd](rest);
54
+ }
55
+ }
56
+ }
57
+
58
+ // start for a module whose imports may suspend (JSPI): _start is wrapped
59
+ // with WebAssembly.promising, so a WebAssembly.Suspending import can await.
60
+ async startAsync(instance) {
61
+ this.memory = instance.exports.memory;
62
+ try {
63
+ await WebAssembly.promising(instance.exports._start)();
64
+ return 0;
65
+ } catch (e) {
66
+ if (e instanceof WasiExit) return e.code;
67
+ throw e;
68
+ } finally {
69
+ for (const fd of [1, 2]) {
70
+ const rest = this.decoders[fd].decode();
71
+ if (rest) this.out[fd](rest);
72
+ }
73
+ }
74
+ }
75
+
76
+ // The memory can grow, so views are made per call.
77
+ get #view() { return new DataView(this.memory.buffer); }
78
+ get #bytes() { return new Uint8Array(this.memory.buffer); }
79
+
80
+ #putStrings(strings, ptrs, buf) {
81
+ const enc = new TextEncoder();
82
+ const view = this.#view, bytes = this.#bytes;
83
+ for (const s of strings) {
84
+ const b = enc.encode(s + "\0");
85
+ view.setUint32(ptrs, buf, true);
86
+ bytes.set(b, buf);
87
+ ptrs += 4;
88
+ buf += b.length;
89
+ }
90
+ return ESUCCESS;
91
+ }
92
+
93
+ #sizes(strings, countPtr, sizePtr) {
94
+ const enc = new TextEncoder();
95
+ const view = this.#view;
96
+ view.setUint32(countPtr, strings.length, true);
97
+ view.setUint32(sizePtr, strings.reduce((n, s) => n + enc.encode(s).length + 1, 0), true);
98
+ return ESUCCESS;
99
+ }
100
+
101
+ #imports() {
102
+ const impl = {
103
+ args_get: (argv, buf) => this.#putStrings(this.args, argv, buf),
104
+ args_sizes_get: (c, s) => this.#sizes(this.args, c, s),
105
+ environ_get: (envp, buf) => this.#putStrings(this.env, envp, buf),
106
+ environ_sizes_get: (c, s) => this.#sizes(this.env, c, s),
107
+
108
+ clock_res_get: (_id, out) => {
109
+ this.#view.setBigUint64(out, 1000n, true);
110
+ return ESUCCESS;
111
+ },
112
+ clock_time_get: (id, _precision, out) => {
113
+ const ns = id === 0
114
+ ? BigInt(Date.now()) * 1_000_000n // REALTIME
115
+ : BigInt(Math.round(performance.now() * 1e6)); // MONOTONIC etc.
116
+ this.#view.setBigUint64(out, ns, true);
117
+ return ESUCCESS;
118
+ },
119
+
120
+ random_get: (buf, len) => {
121
+ // getRandomValues takes at most 65536 bytes per call.
122
+ for (let off = 0; off < len; off += 65536) {
123
+ crypto.getRandomValues(new Uint8Array(this.memory.buffer, buf + off, Math.min(65536, len - off)));
124
+ }
125
+ return ESUCCESS;
126
+ },
127
+
128
+ fd_write: (fd, iovs, iovsLen, nwritten) => {
129
+ if (!(fd in this.out)) return EBADF;
130
+ const view = this.#view;
131
+ let total = 0;
132
+ for (let i = 0; i < iovsLen; i++) {
133
+ const ptr = view.getUint32(iovs + i * 8, true);
134
+ const len = view.getUint32(iovs + i * 8 + 4, true);
135
+ const bytes = new Uint8Array(this.memory.buffer, ptr, len);
136
+ if (this.outBytes[fd]) {
137
+ if (len) this.outBytes[fd](bytes.slice()); // the memory is reused
138
+ } else {
139
+ const text = this.decoders[fd].decode(bytes, { stream: true });
140
+ if (text) this.out[fd](text);
141
+ }
142
+ total += len;
143
+ }
144
+ view.setUint32(nwritten, total, true);
145
+ return ESUCCESS;
146
+ },
147
+ fd_read: (fd, iovs, iovsLen, nread) => {
148
+ if (fd !== 0) return EBADF;
149
+ const view = this.#view, bytes = this.#bytes;
150
+ let total = 0;
151
+ for (let i = 0; i < iovsLen && this.stdinPos < this.stdin.length; i++) {
152
+ const ptr = view.getUint32(iovs + i * 8, true);
153
+ const len = view.getUint32(iovs + i * 8 + 4, true);
154
+ const chunk = this.stdin.subarray(this.stdinPos, this.stdinPos + len);
155
+ bytes.set(chunk, ptr);
156
+ this.stdinPos += chunk.length;
157
+ total += chunk.length;
158
+ }
159
+ view.setUint32(nread, total, true); // 0 = EOF
160
+ return ESUCCESS;
161
+ },
162
+ fd_fdstat_get: (fd, out) => {
163
+ if (fd > 2) return EBADF;
164
+ const view = this.#view;
165
+ view.setUint8(out, 2); // filetype: CHARACTER_DEVICE
166
+ view.setUint16(out + 2, 0, true); // flags
167
+ view.setBigUint64(out + 8, ~0n & 0xffffffffffffffffn, true); // rights_base
168
+ view.setBigUint64(out + 16, ~0n & 0xffffffffffffffffn, true); // rights_inheriting
169
+ return ESUCCESS;
170
+ },
171
+ fd_fdstat_set_flags: (fd) => (fd > 2 ? EBADF : ESUCCESS),
172
+ fd_close: (fd) => (fd > 2 ? EBADF : ESUCCESS),
173
+ fd_seek: (fd) => (fd > 2 ? EBADF : ESPIPE),
174
+ fd_pread: (fd) => (fd > 2 ? EBADF : ESPIPE),
175
+ fd_pwrite: (fd) => (fd > 2 ? EBADF : ESPIPE),
176
+ // EBADF from fd 3 tells wasi-libc there are no preopened directories.
177
+ fd_prestat_get: () => EBADF,
178
+ fd_prestat_dir_name: () => EBADF,
179
+ path_open: () => EBADF,
180
+ path_filestat_get: () => EBADF,
181
+
182
+ poll_oneoff: () => ENOSYS,
183
+ sched_yield: () => ESUCCESS,
184
+ proc_raise: () => ENOSYS,
185
+ proc_exit: (code) => { throw new WasiExit(code); },
186
+ };
187
+ return new Proxy(impl, {
188
+ get: (target, name) => target[name] ?? ((..._) => ENOSYS),
189
+ });
190
+ }
191
+ }
@@ -0,0 +1,70 @@
1
+ module Edgeway
2
+ # The app's entry point: Edgeway.application.call(request) -> response.
3
+ # How a request gets in and the response out (CGI, later a reactor) is the
4
+ # job of Edgeway::Server.
5
+ class Application
6
+ attr_reader :routes
7
+
8
+ def initialize
9
+ @routes = Routing::RouteSet.new
10
+ end
11
+
12
+ def call(request)
13
+ request.read_body
14
+ route, path_params = @routes.recognize(request.method, request.path)
15
+ # Path parameters last: they win over the body's and the query's.
16
+ request.add_path_params(path_params)
17
+ Controller::Dispatcher.dispatch(route, request)
18
+ rescue RecordInvalid => e
19
+ # The error's own list (errors) is the body.
20
+ Controller::Rendering.json_response(422, JSON.generate({ "errors" => e.errors }))
21
+ rescue => e
22
+ rescue_default(e)
23
+ end
24
+
25
+ # What a request answers when no controller handled the error.
26
+ def rescue_default(e)
27
+ if e.is_a?(BadRequest)
28
+ message(400, e.message)
29
+ elsif e.is_a?(InvalidToken)
30
+ # The reason (e.message) goes to the log only; the client gets a fixed
31
+ # body and challenge, so a probe learns nothing from the wording.
32
+ Edgeway.logger.info "401 #{e.message}"
33
+ unauthorized("invalid token", 'Bearer error="invalid_token"')
34
+ elsif e.is_a?(Unauthorized)
35
+ Edgeway.logger.info "401 #{e.message}"
36
+ unauthorized("authentication required", "Bearer")
37
+ elsif e.is_a?(RecordNotUnique)
38
+ # The message names the table and columns: the log only.
39
+ Edgeway.logger.info "409 #{e.message}"
40
+ message(409, "record not unique")
41
+ elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
42
+ # The message can hold the path or an id from the request: the log only.
43
+ Edgeway.logger.info "404 #{e.message}"
44
+ message(404, "not found")
45
+ else
46
+ # The details go to the log (stderr), never to the client.
47
+ Edgeway.logger.error "#{e.class}: #{e.message}"
48
+ message(500, "internal error")
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def unauthorized(text, challenge)
55
+ response = message(401, text)
56
+ response.headers["WWW-Authenticate"] = challenge
57
+ response
58
+ end
59
+
60
+ def message(status, text)
61
+ # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
62
+ # Spinel refuses this String as it is, or emits C that doesn't compile.
63
+ Controller::Rendering.json_response(status, JSON.generate({ "error" => text.dup }))
64
+ end
65
+ end
66
+
67
+ def self.application
68
+ @application ||= Application.new
69
+ end
70
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class AppConfig
6
+ # adapter: => the file build/main.rb requires for it.
7
+ ADAPTERS = {
8
+ "d1" => "edgeway/service/cloudflare/d1",
9
+ "memory" => "edgeway/db/memory"
10
+ }.freeze
11
+
12
+ attr_reader :env, :adapter, :binding_name
13
+
14
+ def self.load(root, env)
15
+ path = File.join(root, "config", "database.yml")
16
+ raise Error, "no config/database.yml in #{root} (is this an Edgeway app?)" unless File.exist?(path)
17
+
18
+ section = (YAML.safe_load_file(path) || {})[env]
19
+ raise Error, "config/database.yml has no #{env} section" unless section.is_a?(Hash)
20
+
21
+ new(env, section["adapter"].to_s, (section["binding"] || "DB").to_s)
22
+ end
23
+
24
+ def initialize(env, adapter, binding_name)
25
+ raise Error, "unknown database adapter #{adapter.inspect} (one of #{ADAPTERS.keys.join(", ")})" unless ADAPTERS.key?(adapter)
26
+
27
+ @env = env
28
+ @adapter = adapter
29
+ @binding_name = binding_name
30
+ end
31
+
32
+ def adapter_file = ADAPTERS.fetch(@adapter)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class AppFiles
6
+ def initialize(root)
7
+ @root = root
8
+ end
9
+
10
+ def require_lines
11
+ lines = []
12
+ app = File.join(@root, "app")
13
+ dirs = Dir.exist?(app) ? Dir.children(app).sort.select { |d| File.directory?(File.join(app, d)) } : []
14
+ dirs.each do |d|
15
+ next if %w[models controllers].include?(d)
16
+
17
+ lines.concat(requires("app/#{d}"))
18
+ end
19
+ lines.concat(requires("app/models"))
20
+ lines.concat(requires("app/controllers"))
21
+ lines << 'require_relative "../config/routes"'
22
+ lines
23
+ end
24
+
25
+ # require_relative lines for the .rb files under dir, application_*.rb first.
26
+ def requires(dir)
27
+ full = File.join(@root, dir)
28
+ return [] unless Dir.exist?(full)
29
+
30
+ files = Dir.glob("**/*.rb", base: full).map { |f| File.join(dir, f) }
31
+ first, rest = files.partition { |f| File.basename(f).start_with?("application_") }
32
+ # String#dump quotes a path with spaces, quotes or "#{" safely.
33
+ (first.sort + rest.sort).map { |f| "require_relative #{"../#{f.delete_suffix(".rb")}".dump}" }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module Edgeway
7
+ module CLI
8
+ # edgeway build: app/, config/ -> build/main.rb -> build/app.wasm, and the
9
+ # gem's js/ -> build/edgeway/.
10
+ #
11
+ # Spinel resolves require at compile time and has no autoloading, so
12
+ # build/main.rb requires every file, in an order where a class body finds
13
+ # what it names (a superclass, an extended module):
14
+ # 1. edgeway, the database adapter config/database.yml names, and the JWT
15
+ # verifier
16
+ # 2. app/*/ other than models/ and controllers/ (services, ...)
17
+ # 3. app/models/
18
+ # 4. app/controllers/
19
+ # 5. config/routes.rb
20
+ # Within each, application_*.rb come first (the base classes), then the
21
+ # rest by path (byte order).
22
+ class Build
23
+ def self.run(args)
24
+ env = "development"
25
+ OptionParser.new do |o|
26
+ o.banner = "Usage: edgeway build [--env ENV]"
27
+ o.on("--env ENV", "environment (default: development)") { |v| env = v }
28
+ end.parse!(args)
29
+ new(Dir.pwd, env).run
30
+ end
31
+
32
+ def initialize(root, env)
33
+ @root = root
34
+ @env = env
35
+ end
36
+
37
+ def run
38
+ raise Error, "no config/routes.rb in #{@root}" unless File.exist?(File.join(@root, "config/routes.rb"))
39
+
40
+ config = AppConfig.load(@root, @env)
41
+ FileUtils.mkdir_p(File.join(@root, "build"))
42
+ write_main(config)
43
+ copy_js(config)
44
+ compile
45
+ puts "built build/app.wasm (#{@env})"
46
+ end
47
+
48
+ private
49
+
50
+ def write_main(config)
51
+ lines = ["# Generated by `edgeway build` from app/ and config/; do not edit.", 'require "edgeway"']
52
+ lines << "require #{config.adapter_file.dump}"
53
+ # Always: js/cloudflare.js supplies the edgeway_jwt import to every app,
54
+ # and the verifier is only called by an app that authenticates.
55
+ lines << 'require "edgeway/service/cloudflare/jwt"'
56
+ lines << ""
57
+ lines << connection_line(config)
58
+ lines << "Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new"
59
+ lines << ""
60
+ lines.concat(AppFiles.new(@root).require_lines)
61
+ lines << ""
62
+ lines << "Edgeway::Server.run"
63
+ File.write(File.join(@root, "build/main.rb"), lines.join("\n") + "\n")
64
+ end
65
+
66
+ def connection_line(config)
67
+ case config.adapter
68
+ when "d1" then %(Edgeway::Model.connection = Edgeway::Cloudflare::D1Adapter.new(binding: #{config.binding_name.inspect}))
69
+ else "Edgeway::Model.connection = Edgeway::DB::MemoryAdapter.new"
70
+ end
71
+ end
72
+
73
+ def copy_js(config)
74
+ dest = File.join(@root, "build/edgeway")
75
+ FileUtils.mkdir_p(dest)
76
+ Dir.glob(File.join(CLI.gem_dir, "js/*.js")).each { |f| FileUtils.cp(f, dest) }
77
+ js = "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
78
+ "export default #{JSON.generate({ "env" => config.env, "d1Binding" => config.binding_name })};\n"
79
+ File.write(File.join(dest, "config.js"), js)
80
+ end
81
+
82
+ def compile
83
+ spinel = Toolchain.spinel || raise(Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)")
84
+ cmd = [spinel, "-I", File.join(CLI.gem_dir, "lib"), "--target=wasm32-wasi", "build/main.rb", "-o", "build/app.wasm"]
85
+ puts cmd.join(" ")
86
+ system(*cmd, chdir: @root) || raise(Error, "spinel failed")
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inflector"
4
+ require_relative "package_manager"
5
+
6
+ module Edgeway
7
+ module CLI
8
+ class Generate
9
+ USAGE = <<~TEXT
10
+ Usage: edgeway generate model NAME [field:type ...]
11
+ edgeway generate controller NAME [action ...]
12
+
13
+ Types: #{%w[string text integer float boolean datetime].join(", ")} (default string)
14
+ TEXT
15
+
16
+ # field type => [the column's SQL type, its attribute type]
17
+ # D1 is SQLite, which has no datetime/boolean type (only NULL, INTEGER,
18
+ # REAL, TEXT and BLOB): a datetime is TEXT (ISO 8601, as Model.now
19
+ # writes it) and a boolean is an INTEGER 0/1. It is SQLite's, not a
20
+ # Cloudflare, limit.
21
+ 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"]
28
+ }.freeze
29
+
30
+ RESOURCE_ACTIONS = %w[index show create update destroy].freeze
31
+
32
+ def self.run(args)
33
+ kind, name, *rest = args
34
+ raise Error, USAGE unless %w[model controller].include?(kind) && name && !name.start_with?("-")
35
+
36
+ generator = new(Dir.pwd)
37
+ kind == "model" ? generator.model(name, rest) : generator.controller(name, rest)
38
+ end
39
+
40
+ def initialize(root)
41
+ @root = root
42
+ return if File.exist?(File.join(root, "config/routes.rb"))
43
+
44
+ raise Error, "no config/routes.rb in #{root}; run this inside an app made by `edgeway new`"
45
+ end
46
+
47
+ def model(name, fields)
48
+ base = underscore(name)
49
+ table = Inflector.pluralize(base)
50
+ 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
+ end
56
+
57
+ def controller(name, actions)
58
+ base = underscore(name).delete_suffix("_controller")
59
+ 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}" }
64
+ end
65
+
66
+ private
67
+
68
+ def underscore(name)
69
+ raise Error, "invalid name: #{name}" unless name.match?(/\A[A-Za-z][A-Za-z0-9_]*\z/)
70
+
71
+ name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
72
+ end
73
+
74
+ def camelize(name) = name.split("_").map(&:capitalize).join
75
+
76
+ # "title:string" => ["title", "TEXT", ":string"]
77
+ def parse_field(spec)
78
+ name, type = spec.split(":", 2)
79
+ type ||= "string"
80
+ raise Error, "invalid field name: #{spec}" unless name.match?(/\A[a-z_][a-z0-9_]*\z/)
81
+ raise Error, "unknown field type #{type} (#{TYPES.keys.join(", ")})" unless TYPES.key?(type)
82
+
83
+ [name, *TYPES[type]]
84
+ end
85
+
86
+ def model_source(klass, columns)
87
+ lines = columns.map do |name, _sql, attr|
88
+ attr == ":boolean" ? " attribute :#{name}, :boolean, default: false" : " attribute :#{name}, #{attr}"
89
+ end
90
+ lines << " timestamps"
91
+ "class #{klass} < ApplicationModel\n#{lines.join("\n")}\nend\n"
92
+ end
93
+
94
+ def migration_source(table, columns)
95
+ lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
96
+ columns.each { |name, sql, _attr| lines << " #{name} #{sql}" }
97
+ lines << " created_at TEXT" << " updated_at TEXT"
98
+ "CREATE TABLE #{table} (\n#{lines.join(",\n")}\n);\n"
99
+ end
100
+
101
+ def model_test_source(klass)
102
+ <<~RUBY
103
+ require "edgeway/test"
104
+ require "test_helper"
105
+
106
+ class #{klass}Test < Edgeway::UnitTest
107
+ def test_starts_empty
108
+ assert_equal 0, #{klass}.count
109
+ end
110
+ end
111
+ RUBY
112
+ end
113
+
114
+ def controller_source(klass, actions)
115
+ defs = actions.map { |a| " def #{a} = render json: {}\n" }
116
+ "class #{klass}Controller < ApplicationController\n#{defs.join("\n")}end\n"
117
+ end
118
+
119
+ def controller_test_source(klass)
120
+ <<~RUBY
121
+ require "edgeway/test"
122
+ require "test_helper"
123
+
124
+ class #{klass}Test < Edgeway::IntegrationTest
125
+ # def test_index
126
+ # get "/#{underscore(klass)}"
127
+ # assert_response 200
128
+ # end
129
+ end
130
+ RUBY
131
+ end
132
+
133
+ def route_hints(base, actions)
134
+ return ["r.resources :#{base}"] if actions.empty?
135
+
136
+ standard = actions & RESOURCE_ACTIONS
137
+ # Before the resources: "/todos/archive" would match "/todos/:id" first.
138
+ hints = (actions - standard).map { |a| "r.get \"/#{base}/#{a}\", to: \"#{base}##{a}\"" }
139
+ hints << "r.resources :#{base}, only: [#{standard.map { |a| ":#{a}" }.join(", ")}]" unless standard.empty?
140
+ hints
141
+ end
142
+
143
+ # NNNN, one above the highest numbered file in db/migrations (0001 for none).
144
+ def next_migration_number
145
+ dir = File.join(@root, "db/migrations")
146
+ last = Dir.exist?(dir) ? Dir.children(dir).filter_map { |f| f[/\A\d+/]&.to_i }.max.to_i : 0
147
+ format("%04d", last + 1)
148
+ end
149
+
150
+ def write(rel, text)
151
+ dest = File.join(@root, rel)
152
+ raise Error, "#{rel} already exists" if File.exist?(dest)
153
+
154
+ FileUtils.mkdir_p(File.dirname(dest))
155
+ File.write(dest, text)
156
+ puts " create #{rel}"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Edgeway
6
+ module CLI
7
+ class New
8
+ TEMPLATES = File.expand_path("../generators/app/templates", __dir__)
9
+
10
+ def self.run(args)
11
+ name = args.first
12
+ raise Error, "Usage: edgeway new APP" if name.nil? || name.start_with?("-")
13
+ raise Error, "#{name} already exists" if File.exist?(name)
14
+
15
+ new(name).run
16
+ end
17
+
18
+ def initialize(dir)
19
+ @dir = dir
20
+ @name = File.basename(File.expand_path(dir)).gsub(/[^A-Za-z0-9_-]/, "-")
21
+ @version = Edgeway::VERSION
22
+ end
23
+
24
+ def run
25
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: TEMPLATES).sort.each do |rel|
26
+ src = File.join(TEMPLATES, rel)
27
+ next if File.directory?(src) || File.basename(rel) == "."
28
+
29
+ dest = File.join(@dir, rel.delete_suffix(".tt"))
30
+ FileUtils.mkdir_p(File.dirname(dest))
31
+ if rel.end_with?(".tt")
32
+ File.write(dest, ERB.new(File.read(src), trim_mode: "-").result(binding))
33
+ else
34
+ FileUtils.cp(src, dest)
35
+ end
36
+ FileUtils.chmod(0o755, dest) if File.executable?(src)
37
+ puts " create #{dest}"
38
+ end
39
+ puts "\nNext: cd #{@dir} && bin/setup && bundle exec edgeway test && bin/build"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ # The JavaScript package manager of an app made by `edgeway new`: the one
6
+ # place that names it. The templates (bin/*, README, mise.toml,
7
+ # wrangler.toml) ask it for the commands, so supporting another is a
8
+ # change here (and a way to choose it in New).
9
+ module PackageManager
10
+ NAME = "pnpm"
11
+
12
+ def self.name = NAME
13
+
14
+ # Installs package.json's dependencies (wrangler).
15
+ def self.install = "#{NAME} install"
16
+
17
+ # Runs a binary of the app's node_modules: `#{exec} wrangler dev`.
18
+ def self.exec = "#{NAME} exec"
19
+ end
20
+ end
21
+ end