edgeway 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -28,6 +28,17 @@ module Edgeway
28
28
  # ({"error":"invalid token"}) and the challenge no description.
29
29
  class InvalidToken < Unauthorized; end
30
30
 
31
+ # A request over a rate limit: 429 with a fixed body. retry_after is the
32
+ # seconds for a Retry-After header, 0 for none.
33
+ class TooManyRequests < Error
34
+ attr_reader :retry_after
35
+
36
+ def initialize(message = "rate limit exceeded", retry_after = 0)
37
+ super(message)
38
+ @retry_after = retry_after.is_a?(Integer) && Integer(retry_after) > 0 ? retry_after : 0
39
+ end
40
+ end
41
+
31
42
  # A record that doesn't exist: 404.
32
43
  class RecordNotFound < Error; end
33
44
 
@@ -41,6 +52,10 @@ module Edgeway
41
52
  # details only in the log.
42
53
  class InvalidHeader < Error; end
43
54
 
55
+ # render named a template that was not compiled from app/views: 500, with
56
+ # the name only in the log, since it is a bug in the app.
57
+ class MissingTemplate < Error; end
58
+
44
59
  # A failure in the database adapter: 500.
45
60
  class DatabaseError < Error; end
46
61
 
@@ -49,6 +64,34 @@ module Edgeway
49
64
  # the log only.
50
65
  class RecordNotUnique < DatabaseError; end
51
66
 
67
+ # A write the database refused for a NOT NULL constraint: 500, since it is a
68
+ # bug in the app. The message goes to the log only.
69
+ class NotNullViolation < DatabaseError; end
70
+
71
+ # A write the database refused for a FOREIGN KEY constraint: 500, since it is
72
+ # a bug in the app. The message goes to the log only.
73
+ class InvalidForeignKey < DatabaseError; end
74
+
75
+ # A write the database refused for a CHECK constraint: 500, since it is a bug
76
+ # in the app. The message goes to the log only.
77
+ class CheckViolation < DatabaseError; end
78
+
79
+ # A transient database failure or overload (a lost connection, a reset object...):
80
+ # 503 with "Retry-After: 1" and a fixed body. The message goes to the log
81
+ # only. Edgeway never retries it. A write that fails this way may still have
82
+ # been applied, so only an idempotent request is safe to retry.
83
+ class DatabaseUnavailable < DatabaseError; end
84
+
85
+ module HTTP
86
+ # An outgoing request that failed (a network error, a URL that is refused,
87
+ # a response over the size limit): 500. The message goes to the log only.
88
+ class Error < Edgeway::Error; end
89
+
90
+ # An outgoing request that did not finish within its timeout: 500. The
91
+ # message goes to the log only.
92
+ class Timeout < Error; end
93
+ end
94
+
52
95
  # A record that failed its validations: 422 with {"errors": [...]}.
53
96
  class RecordInvalid < Error
54
97
  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
+ # The Ruby in .ruby-version.
24
+ - name: Set up Ruby
25
+ uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
26
+ with:
27
+ bundler-cache: true
28
+ # Everything in mise.toml: the WASI SDK (bin/build needs it), Spinel,
29
+ # Node.js, and <%= PackageManager.name %>.
30
+ - name: Install the tools
31
+ uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
32
+ # `edgeway test` runs the tests on the local D1 of wrangler.
33
+ - name: Install wrangler
34
+ run: <%= PackageManager.install %>
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,20 @@ 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
+
20
+ Files in `public/` are served by Cloudflare before the app, for every method on their path (see Edgeway's README, "Static assets").
21
+
18
22
  Without mise, set `SPINEL` to the Spinel compiler and `WASI_SDK` to the WASI SDK.
19
23
 
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).
24
+ 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. The tests are compiled to wasm like the app and run by node on wrangler's local D1 (in memory, so `.wrangler/state` is not touched); each test gets an emptied database with `db/migrations/*.sql` applied. This needs the tools of `bin/setup` (Spinel, the WASI SDK, Node.js and wrangler).
25
+
26
+ `.github/workflows/ci.yml` runs `bin/test` and `bin/build --release` on GitHub Actions for pushes to main and pull requests.
21
27
 
22
28
  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 "$@"
@@ -3,8 +3,7 @@
3
3
  set -euo pipefail
4
4
  cd "$(dirname "$0")/.."
5
5
 
6
- # Spinel and the WASI SDK, from mise.toml. `edgeway test` also links libsqlite3
7
- # (libsqlite3-dev on Debian/Ubuntu; part of macOS).
6
+ # Spinel, the WASI SDK and Node.js, from mise.toml.
8
7
  if command -v mise >/dev/null 2>&1; then
9
8
  mise trust --quiet
10
9
  mise install
@@ -15,7 +14,7 @@ fi
15
14
 
16
15
  bundle install
17
16
 
18
- # wrangler, from package.json.
17
+ # wrangler, from package.json (`edgeway test` runs the tests on its local D1).
19
18
  if ! command -v <%= PackageManager.name %> >/dev/null 2>&1; then
20
19
  echo "bin/setup: <%= PackageManager.name %> is not installed; install it to get wrangler." >&2
21
20
  exit 1
@@ -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 "$@"
@@ -0,0 +1,22 @@
1
+ # The cache store of each environment (edgeway build --env ENV), Edgeway.cache
2
+ # (see the README's "Caching"). Without this file, or a section for the
3
+ # environment, the store is null: it keeps nothing, so Edgeway.cache.fetch
4
+ # always runs its block.
5
+ #
6
+ # store: kv, memory or null. kv keeps entries in a Cloudflare KV
7
+ # namespace; memory keeps them for the one request only.
8
+ # binding: the name of a [[kv_namespaces]] binding in wrangler.toml (kv).
9
+ # namespace: a prefix for every key (optional).
10
+ # expires_in: the seconds an entry lives when the call gives none (optional).
11
+ #
12
+ # `edgeway test` always uses a null store, whatever this file says.
13
+ # production:
14
+ # store: kv
15
+ # binding: CACHE
16
+ # namespace: myapp
17
+ # expires_in: 3600
18
+ development:
19
+ store: null
20
+
21
+ production:
22
+ store: null
@@ -1,7 +1,29 @@
1
1
  # The database of each environment (edgeway build --env ENV), compiled into
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
- # is); memory is for tests.
4
+ # is); memory is a database that stores nothing (as the test: adapter it
5
+ # means native tests on SQLite instead; see the README's "Testing an app").
6
+ #
7
+ # `edgeway test` runs the tests on a local D1 (in memory, like `wrangler dev`)
8
+ # with adapter: d1, each test starting from the migrations (db/migrations and
9
+ # db/<name>/migrations).
10
+ #
11
+ # Another database goes under databases:, which a model picks with
12
+ # `self.database = :analytics` (see the README's "Multiple databases").
13
+ # It has its own binding, migrations (db/<name>/migrations) and test database.
14
+ # production:
15
+ # adapter: d1
16
+ # binding: DB
17
+ # databases:
18
+ # analytics:
19
+ # binding: ANALYTICS
20
+ #
21
+ # For a database with read replication, `sessions: true` next to its binding
22
+ # (see the README's "Read replication (Sessions)").
23
+ # production:
24
+ # adapter: d1
25
+ # binding: DB
26
+ # sessions: true
5
27
  development:
6
28
  adapter: d1
7
29
  binding: DB
@@ -11,4 +33,5 @@ production:
11
33
  binding: DB
12
34
 
13
35
  test:
14
- adapter: memory
36
+ adapter: d1
37
+ 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"
@@ -0,0 +1 @@
1
+ # Files in public/ not to upload as static assets (.gitignore syntax).
@@ -2,6 +2,11 @@ name = "<%= @name %>"
2
2
  main = "src/worker.js"
3
3
  compatibility_date = "2026-10-01"
4
4
 
5
+ # Workers Logs records what the app logs (Edgeway.logger), one entry per line,
6
+ # with its fields indexed (see the README's "Logging").
7
+ [observability]
8
+ enabled = true
9
+
5
10
  # CORS, for a frontend on another origin (see the README's "CORS"): the
6
11
  # origins allowed, comma-separated and compared exactly (no trailing slash).
7
12
  # Unset, CORS is off. CORS_HEADERS defaults to "Authorization, Content-Type",
@@ -11,6 +16,14 @@ compatibility_date = "2026-10-01"
11
16
  # [vars]
12
17
  # CORS_ORIGINS = "http://localhost:3000, https://app.example"
13
18
 
19
+ # Files in public/ are served as is, without starting the app, so they are
20
+ # always public: `before_action :authenticate!` never sees them. A file takes
21
+ # its path from the app for every method (other than GET/HEAD it is 405):
22
+ # public/index.html answers `/`, public/todos.html `/todos`. Anything else goes
23
+ # to the app. For an SPA, see Edgeway's README, "Static assets".
24
+ [assets]
25
+ directory = "./public"
26
+
14
27
  # Simulated locally by `wrangler dev`. Create tables with
15
28
  # `<%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local`.
16
29
  # database_id is a placeholder (all zeros): that is enough for --local and
@@ -21,3 +34,29 @@ binding = "DB"
21
34
  database_name = "<%= @name %>-db"
22
35
  database_id = "00000000-0000-0000-0000-000000000000"
23
36
  migrations_dir = "db/migrations"
37
+
38
+ # A second database (config/database.yml's databases:) is another binding,
39
+ # with an id of its own: the same id is the same local database:
40
+ # [[d1_databases]]
41
+ # binding = "ANALYTICS"
42
+ # database_name = "<%= @name %>-analytics"
43
+ # database_id = "00000000-0000-0000-0000-000000000001"
44
+ # migrations_dir = "db/analytics/migrations"
45
+
46
+ # The cache (config/cache.yml, `store: kv`; see Edgeway's README, "Caching") keeps
47
+ # its entries in a KV namespace. Simulated locally by `wrangler dev`. Before a
48
+ # deploy, create the namespace (`<%= PackageManager.exec %> wrangler kv namespace create CACHE`)
49
+ # and put the id it prints here.
50
+ # [[kv_namespaces]]
51
+ # binding = "CACHE"
52
+ # id = "00000000000000000000000000000000"
53
+
54
+ # Rate limiting (`rate_limit "API_LIMITER"` in a controller; see Edgeway's README,
55
+ # "Rate limiting"). The period is 10 or 60 seconds; namespace_id is a positive
56
+ # integer, as a string, that is unique in your account. `wrangler dev` enforces it.
57
+ # [[ratelimits]]
58
+ # name = "API_LIMITER"
59
+ # namespace_id = "1001"
60
+ # [ratelimits.simple]
61
+ # limit = 100
62
+ # period = 60
@@ -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)
@@ -0,0 +1,80 @@
1
+ module Edgeway
2
+ module HTTP
3
+ DEFAULT_TIMEOUT = 10
4
+ MAX_TIMEOUT = 300
5
+
6
+ class ClientResponse
7
+ attr_reader :status, :headers, :body
8
+
9
+ def initialize(status, headers, body)
10
+ @status = status
11
+ @headers = headers
12
+ @body = body
13
+ end
14
+
15
+ def success? = @status >= 200 && @status < 300
16
+
17
+ def json = JSON.parse(@body)
18
+ end
19
+
20
+ class Transport
21
+ def request(payload)
22
+ JSON.generate({ "error" => "no HTTP transport is set" })
23
+ end
24
+ end
25
+
26
+ @client = Transport.new
27
+
28
+ def self.client = @client
29
+
30
+ def self.client=(client)
31
+ @client = client
32
+ end
33
+
34
+ def self.get(url, headers: {}, timeout: DEFAULT_TIMEOUT)
35
+ send_request("GET", url, "", headers, timeout)
36
+ end
37
+
38
+ def self.delete(url, headers: {}, timeout: DEFAULT_TIMEOUT)
39
+ send_request("DELETE", url, "", headers, timeout)
40
+ end
41
+
42
+ def self.post(url, body: "", headers: {}, timeout: DEFAULT_TIMEOUT)
43
+ send_request("POST", url, body, headers, timeout)
44
+ end
45
+
46
+ def self.put(url, body: "", headers: {}, timeout: DEFAULT_TIMEOUT)
47
+ send_request("PUT", url, body, headers, timeout)
48
+ end
49
+
50
+ def self.patch(url, body: "", headers: {}, timeout: DEFAULT_TIMEOUT)
51
+ send_request("PATCH", url, body, headers, timeout)
52
+ end
53
+
54
+ def self.send_request(verb, url, body, headers, timeout)
55
+ timeout_ms = timeout_milliseconds(timeout)
56
+ payload = JSON.generate({
57
+ "method" => verb, "url" => url, "headers" => headers, "body" => body,
58
+ "timeout_ms" => timeout_ms
59
+ })
60
+ parse_answer(client.request(payload))
61
+ end
62
+
63
+ def self.timeout_milliseconds(timeout)
64
+ milliseconds = timeout > MAX_TIMEOUT ? 0 : (timeout * 1000).to_i
65
+ raise ArgumentError, "timeout must be 0.001 to #{MAX_TIMEOUT} seconds" if milliseconds < 1
66
+
67
+ milliseconds
68
+ end
69
+
70
+ def self.parse_answer(text)
71
+ answer = JSON.parse(text)
72
+ raise Timeout, answer["error"] if answer.key?("timeout")
73
+ raise Error, answer["error"] if answer.key?("error")
74
+
75
+ body = answer["body_base64"].unpack1("m0")
76
+ body.force_encoding("UTF-8")
77
+ ClientResponse.new(answer["status"], answer["headers"], body)
78
+ end
79
+ end
80
+ end
@@ -25,6 +25,11 @@ module Edgeway
25
25
  ENV["HTTP_" + name.upcase.gsub("-", "_")] || ""
26
26
  end
27
27
 
28
+ # The client address the platform reports, "" when unknown.
29
+ def remote_addr
30
+ ENV["REMOTE_ADDR"] || ""
31
+ end
32
+
28
33
  def json?
29
34
  @content_type.start_with?("application/json")
30
35
  end
@@ -63,12 +68,12 @@ module Edgeway
63
68
  add_body_params(decode_pairs(@body, "form body"))
64
69
  end
65
70
 
66
- # decode_www_form never raises on bad bytes: it turns those that aren't
71
+ # URI.decode_www_form never raises on bad bytes: it turns those that aren't
67
72
  # UTF-8 (%ff) into U+FFFD. That is only right when the client sent a
68
73
  # U+FFFD itself, so otherwise one in the result is a BadRequest.
69
74
  def decode_pairs(text, what)
70
75
  raise BadRequest, "#{what} is not valid UTF-8" unless text.valid_encoding?
71
- pairs = decode_www_form(text)
76
+ pairs = URI.decode_www_form(text)
72
77
  sent = text.include?(REPLACEMENT) || text.downcase.include?("%ef%bf%bd")
73
78
  unless sent
74
79
  pairs.each do |k, v|
@@ -78,26 +83,6 @@ module Edgeway
78
83
  pairs
79
84
  end
80
85
 
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
86
  def read_json
102
87
  json = JSONBody.new(@body)
103
88
  @json_body = json.value
@@ -0,0 +1,38 @@
1
+ require_relative "client"
2
+
3
+ module Edgeway
4
+ module HTTP
5
+ class UnstubbedRequest < Edgeway::Error; end
6
+
7
+ class StubTransport < Transport
8
+ attr_reader :requests
9
+
10
+ def initialize
11
+ @answers = {}
12
+ @requests = []
13
+ end
14
+
15
+ def stub(verb, url, status, body, headers)
16
+ lowered = {}
17
+ headers.each { |name, value| lowered[name.to_s.downcase] = value.to_s }
18
+ @answers[key(verb, url)] = JSON.generate({ "status" => status, "headers" => lowered, "body_base64" => [body].pack("m0") })
19
+ end
20
+
21
+ def stub_error(verb, url, message, timeout)
22
+ answer = { "error" => message }
23
+ answer["timeout"] = true if timeout
24
+ @answers[key(verb, url)] = JSON.generate(answer)
25
+ end
26
+
27
+ def request(payload)
28
+ sent = JSON.parse(payload)
29
+ @requests << sent
30
+ @answers[key(sent["method"], sent["url"])] || raise(UnstubbedRequest, "no stub for #{sent["method"]} #{sent["url"]} (add stub_http to the test)")
31
+ end
32
+
33
+ private
34
+
35
+ def key(verb, url) = "#{verb.to_s.upcase} #{url}"
36
+ end
37
+ end
38
+ end
@@ -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
@@ -1,12 +1,40 @@
1
1
  module Edgeway
2
- # Writes to stderr; the runtime sends it to console.error.
3
2
  class Logger
4
- def info(message)
5
- STDERR.puts "[edgeway] #{message}"
3
+ LEVELS = ["debug", "info", "warn", "error"]
4
+
5
+ def initialize(output = STDERR)
6
+ @output = output
7
+ end
8
+
9
+ def debug(message, fields = {})
10
+ write("debug", message, fields)
11
+ end
12
+
13
+ def info(message, fields = {})
14
+ write("info", message, fields)
15
+ end
16
+
17
+ def warn(message, fields = {})
18
+ write("warn", message, fields)
19
+ end
20
+
21
+ def error(message, fields = {})
22
+ write("error", message, fields)
23
+ end
24
+
25
+ private
26
+
27
+ def write(level, message, fields)
28
+ return if LEVELS.index(level) < threshold
29
+ entry = {}
30
+ fields.each { |name, value| entry[name.to_s] = value }
31
+ entry["level"] = level
32
+ entry["message"] = message
33
+ @output.puts JSON.generate(entry)
6
34
  end
7
35
 
8
- def error(message)
9
- STDERR.puts "[edgeway] ERROR #{message}"
36
+ def threshold
37
+ LEVELS.index(Edgeway.config("LOG_LEVEL", "info")) || LEVELS.index("info")
10
38
  end
11
39
  end
12
40