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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +784 -125
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare-kv.js +47 -0
- data/js/cloudflare-ratelimit.js +31 -0
- data/js/cloudflare-test-database.js +112 -0
- data/js/cloudflare-test-runner.js +76 -0
- data/js/cloudflare.js +144 -22
- data/js/cors.js +15 -16
- data/js/fetch.js +113 -0
- data/js/headers.js +16 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +6 -40
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +21 -9
- data/lib/edgeway/cache/limits.rb +8 -0
- data/lib/edgeway/cache/memory_store.rb +24 -0
- data/lib/edgeway/cache/null_store.rb +13 -0
- data/lib/edgeway/cache/store.rb +189 -0
- data/lib/edgeway/cache.rb +13 -0
- 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/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +96 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +50 -18
- data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
- data/lib/edgeway/cli/cloudflare_test.rb +120 -0
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +28 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +7 -4
- data/lib/edgeway/controller/base.rb +32 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- 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 +42 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +43 -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 +8 -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/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +7 -22
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/logger.rb +33 -5
- 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/rate_limit/limiter.rb +9 -0
- data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
- data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
- data/lib/edgeway/rate_limit.rb +2 -0
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +13 -0
- data/lib/edgeway/service/cloudflare/d1.rb +44 -30
- data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
- data/lib/edgeway/service/cloudflare/kv.rb +45 -0
- data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
- data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
- data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
- data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
- data/lib/edgeway/service/fetch.rb +26 -0
- data/lib/edgeway/service/import_result.rb +24 -0
- data/lib/edgeway/test/sqlite_databases.rb +18 -0
- data/lib/edgeway/test.rb +86 -26
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +9 -0
- metadata +50 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
data/lib/edgeway/errors.rb
CHANGED
|
@@ -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
|
|
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`).
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
cd "$(dirname "$0")/.."
|
|
5
5
|
|
|
6
|
-
# Spinel
|
|
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,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
|
|
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:
|
|
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 = "
|
|
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
|
-
|
|
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
|
data/lib/edgeway/http/request.rb
CHANGED
|
@@ -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
|
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
|
data/lib/edgeway/logger.rb
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
module Edgeway
|
|
2
|
-
# Writes to stderr; the runtime sends it to console.error.
|
|
3
2
|
class Logger
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
9
|
-
|
|
36
|
+
def threshold
|
|
37
|
+
LEVELS.index(Edgeway.config("LOG_LEVEL", "info")) || LEVELS.index("info")
|
|
10
38
|
end
|
|
11
39
|
end
|
|
12
40
|
|