gemstack 0.2.5 → 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 +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72fb1f0292d7127940eaf3eab4c5e701a4150c43278fb2579065685700b60ff1
|
|
4
|
+
data.tar.gz: be15ee4fb994153c86320b4218adcfa31bd5e94db1d4a14f9e9e38ecd528338a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed1794c6bc7e19ef464e152b166d901a06f9ee05f0c3e5fa3b2f56e2531a92208250b321704f357b2d04e53f482acba05208edd0b4ff9cd4a3d85a1427b912bc
|
|
7
|
+
data.tar.gz: 5b25b7a0c6e7758ee015f541907df8556fe8160c15558db7dbaa0c1fa1aad451cfcf324d5f3e8e80960193c8166eb39296005cd073af240e3812eaaad5725ea3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
The framework gem now contains core, cache, schema, http, db, jobs, mail, storage, contract, dev and the CLI code. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
+
|
|
3
7
|
## 0.2.5
|
|
4
8
|
|
|
5
9
|
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
data/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
GemStack: a fast, modular Ruby API framework for Next.js applications.
|
|
4
4
|
|
|
5
5
|
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
-
applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed
|
|
7
|
-
version.
|
|
6
|
+
applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed
|
|
7
|
+
together in that repository and released with the same version.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -13,7 +13,11 @@ gem install gemstack
|
|
|
13
13
|
gemstack new myapp
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
This gem is the framework
|
|
16
|
+
This gem is the framework — routing, controllers, models (SQLite, PostgreSQL, MySQL), background jobs,
|
|
17
|
+
mail, file storage, the TypeScript contract, generators and the development server. Apps switch modules
|
|
18
|
+
on in `config/app.rb` (`require "gemstack/db"`, …). Authentication and realtime are the
|
|
19
|
+
[gemstack-auth](https://rubygems.org/gems/gemstack-auth) and
|
|
20
|
+
[gemstack-realtime](https://rubygems.org/gems/gemstack-realtime) gems.
|
|
17
21
|
|
|
18
22
|
## Documentation
|
|
19
23
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# A thread-safe, per-process LRU cache with expiry. Values are stored
|
|
6
|
+
# marshalled, so callers never share (and can't mutate) cached objects —
|
|
7
|
+
# the same semantics as the Redis store. With several Puma workers each
|
|
8
|
+
# has its own copy; use Redis to share a cache across processes.
|
|
9
|
+
class MemoryStore < Store
|
|
10
|
+
Entry = Struct.new(:data, :expires_at)
|
|
11
|
+
|
|
12
|
+
def initialize(max_entries: 10_000, **)
|
|
13
|
+
super(**)
|
|
14
|
+
@max_entries = max_entries
|
|
15
|
+
@entries = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def size = @entries.size
|
|
20
|
+
def clear = @mutex.synchronize { @entries.clear }
|
|
21
|
+
|
|
22
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
23
|
+
normalized = normalize(key)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
entry = live_entry(normalized)
|
|
26
|
+
value = (entry ? Integer(load(entry.data)) : 0) + by
|
|
27
|
+
store(normalized, value, entry ? entry.expires_at : expires_at(expires_in))
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def read_entry(key)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
entry = live_entry(key) or return MISSING
|
|
37
|
+
@entries[key] = @entries.delete(key) # mark as most recently used
|
|
38
|
+
load(entry.data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write_entry(key, value, expires_in)
|
|
43
|
+
data = dump(value)
|
|
44
|
+
@mutex.synchronize { store_data(key, data, expires_at(expires_in)) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_entry(key) = @mutex.synchronize { !@entries.delete(key).nil? }
|
|
48
|
+
|
|
49
|
+
# Callers hold the mutex.
|
|
50
|
+
def live_entry(key)
|
|
51
|
+
entry = @entries[key]
|
|
52
|
+
return nil unless entry
|
|
53
|
+
return entry unless entry.expires_at && entry.expires_at <= monotonic
|
|
54
|
+
|
|
55
|
+
@entries.delete(key)
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def store(key, value, expires_at) = store_data(key, dump(value), expires_at)
|
|
60
|
+
|
|
61
|
+
def store_data(key, data, expires_at)
|
|
62
|
+
@entries.delete(key)
|
|
63
|
+
@entries[key] = Entry.new(data, expires_at)
|
|
64
|
+
@entries.shift while @entries.size > @max_entries # Hash order = LRU order
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# Caches nothing: fetch always runs its block. The default in tests, so
|
|
6
|
+
# tests never depend on state cached by earlier tests.
|
|
7
|
+
class NullStore < Store
|
|
8
|
+
def clear = true
|
|
9
|
+
def increment(_key, by = 1, **) = by
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def read_entry(_key) = MISSING
|
|
14
|
+
def write_entry(_key, _value, _expires_in) = true
|
|
15
|
+
def delete_entry(_key) = false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# A cache shared by every process, through Redis. Uses the `redis-client`
|
|
6
|
+
# gem (add `gem "redis-client"` to the Gemfile) with a connection pool
|
|
7
|
+
# sized to the server's threads, or any client object you pass that
|
|
8
|
+
# responds to #call(*command) (a redis-client pool or connection).
|
|
9
|
+
#
|
|
10
|
+
# config.cache.store = :redis # REDIS_URL
|
|
11
|
+
# config.cache.store = GemStack::Cache::RedisStore.new(client: my_pool, namespace: "shop")
|
|
12
|
+
class RedisStore < Store
|
|
13
|
+
attr_reader :client
|
|
14
|
+
|
|
15
|
+
def initialize(url: nil, client: nil, pool_size: 5, **)
|
|
16
|
+
super(**)
|
|
17
|
+
@client = client || build_client(url, pool_size)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
21
|
+
normalized = normalize(key)
|
|
22
|
+
value = client.call("INCRBY", normalized, by)
|
|
23
|
+
client.call("PEXPIRE", normalized, (Float(expires_in) * 1000).round, "NX") if expires_in
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Deletes only keys in this store's namespace (never FLUSHDB).
|
|
28
|
+
def clear
|
|
29
|
+
raise Error, "RedisStore#clear needs a namespace (config.cache.namespace)" unless namespace
|
|
30
|
+
|
|
31
|
+
cursor = "0"
|
|
32
|
+
loop do
|
|
33
|
+
cursor, keys = client.call("SCAN", cursor, "MATCH", "#{namespace}:*", "COUNT", 500)
|
|
34
|
+
client.call("UNLINK", *keys) unless keys.empty?
|
|
35
|
+
break if cursor == "0"
|
|
36
|
+
end
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def read_entry(key)
|
|
43
|
+
data = client.call("GET", key)
|
|
44
|
+
return MISSING if data.nil?
|
|
45
|
+
|
|
46
|
+
# Counters are stored as plain integers so INCRBY works on them.
|
|
47
|
+
data.match?(/\A-?\d+\z/) ? Integer(data) : load(data.b)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_entry(key, value, expires_in)
|
|
51
|
+
data = value.is_a?(Integer) ? value.to_s : dump(value)
|
|
52
|
+
if expires_in
|
|
53
|
+
client.call("SET", key, data, "PX", (Float(expires_in) * 1000).round)
|
|
54
|
+
else
|
|
55
|
+
client.call("SET", key, data)
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_entry(key) = client.call("DEL", key).positive?
|
|
61
|
+
|
|
62
|
+
def build_client(url, pool_size)
|
|
63
|
+
require "redis-client"
|
|
64
|
+
RedisClient.config(url: url).new_pool(size: pool_size, timeout: 1.0)
|
|
65
|
+
rescue LoadError
|
|
66
|
+
raise ConfigurationError, 'the Redis cache store needs `gem "redis-client"` in the Gemfile'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Cache
|
|
7
|
+
# The store interface. Subclasses implement read_entry / write_entry /
|
|
8
|
+
# delete_entry / clear (and may override increment for atomicity).
|
|
9
|
+
#
|
|
10
|
+
# Keys may be strings, symbols, arrays of parts, or objects with
|
|
11
|
+
# #cache_key (GemStack models have one); they are namespaced and hashed
|
|
12
|
+
# when longer than MAX_KEY bytes. Values must be Marshal-able.
|
|
13
|
+
class Store
|
|
14
|
+
MAX_KEY = 200
|
|
15
|
+
MISSING = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :namespace, :default_expires_in
|
|
18
|
+
|
|
19
|
+
def initialize(namespace: nil, default_expires_in: nil)
|
|
20
|
+
@namespace = namespace&.to_s
|
|
21
|
+
@default_expires_in = default_expires_in
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the cached value, or computes it with the block, stores and
|
|
25
|
+
# returns it. nil results are cached too. force: true recomputes.
|
|
26
|
+
def fetch(key, expires_in: default_expires_in, force: false)
|
|
27
|
+
normalized = normalize(key)
|
|
28
|
+
unless force
|
|
29
|
+
value = read_entry(normalized)
|
|
30
|
+
return value unless value.equal?(MISSING)
|
|
31
|
+
end
|
|
32
|
+
raise ArgumentError, "fetch needs a block on a cache miss" unless block_given?
|
|
33
|
+
|
|
34
|
+
value = yield
|
|
35
|
+
write_entry(normalized, value, expires_in)
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read(key)
|
|
40
|
+
value = read_entry(normalize(key))
|
|
41
|
+
value.equal?(MISSING) ? nil : value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(key, value, expires_in: default_expires_in)
|
|
45
|
+
write_entry(normalize(key), value, expires_in)
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def exist?(key) = !read_entry(normalize(key)).equal?(MISSING)
|
|
50
|
+
def delete(key) = delete_entry(normalize(key))
|
|
51
|
+
|
|
52
|
+
# Not atomic in the base class; stores override it where they can.
|
|
53
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
54
|
+
normalized = normalize(key)
|
|
55
|
+
current = read_entry(normalized)
|
|
56
|
+
value = (current.equal?(MISSING) ? 0 : Integer(current)) + by
|
|
57
|
+
write_entry(normalized, value, expires_in)
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def decrement(key, by = 1, **) = increment(key, -by, **)
|
|
62
|
+
|
|
63
|
+
def normalize(key)
|
|
64
|
+
raw = key_part(key)
|
|
65
|
+
full = namespace ? "#{namespace}:#{raw}" : raw
|
|
66
|
+
full.bytesize > MAX_KEY ? "#{full[0, 100]}:sha256:#{Digest::SHA256.hexdigest(full)}" : full
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def key_part(key)
|
|
72
|
+
case key
|
|
73
|
+
when Array then key.map { |part| key_part(part) }.join("/")
|
|
74
|
+
when String, Symbol, Numeric then key.to_s
|
|
75
|
+
else key.respond_to?(:cache_key) ? key.cache_key.to_s : key.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dump(value) = Marshal.dump(value)
|
|
80
|
+
def load(data) = Marshal.load(data) # rubocop:disable Security/MarshalLoad -- only data this process wrote
|
|
81
|
+
|
|
82
|
+
def expires_at(expires_in)
|
|
83
|
+
expires_in ? monotonic + Float(expires_in) : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/core"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
# Application caching:
|
|
7
|
+
#
|
|
8
|
+
# GemStack.cache.fetch("product:#{id}", expires_in: 300) { Product.find(id) }
|
|
9
|
+
# GemStack.cache.fetch([:stats, Date.today]) { expensive_report } # array keys
|
|
10
|
+
# GemStack.cache.increment("signups")
|
|
11
|
+
# GemStack.cache.delete("product:#{id}")
|
|
12
|
+
#
|
|
13
|
+
# Stores are swappable (config.cache.store): :memory (default), :null
|
|
14
|
+
# (default in test), :redis, or any object implementing the Store interface.
|
|
15
|
+
# Redis is never required.
|
|
16
|
+
module Cache
|
|
17
|
+
class Config < Settings
|
|
18
|
+
setting :store, default: -> { GemStack.env.test? ? :null : :memory }
|
|
19
|
+
# Prefix for every key ("shop:product:1"). Required for Redis#clear.
|
|
20
|
+
setting :namespace, default: -> { GemStack.config.name }
|
|
21
|
+
# Seconds; nil = no expiry unless given per call.
|
|
22
|
+
setting :default_expires_in, default: nil
|
|
23
|
+
# MemoryStore: maximum entries per process before the least recently used are evicted.
|
|
24
|
+
setting :max_entries, default: 10_000
|
|
25
|
+
setting :redis_url, default: -> { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
26
|
+
setting :redis_pool_size, default: -> { Integer(ENV.fetch("GEMSTACK_MAX_THREADS", 5)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def build(setting = GemStack.config.cache.store, config: GemStack.config.cache)
|
|
31
|
+
options = { namespace: config.namespace, default_expires_in: config.default_expires_in }
|
|
32
|
+
case setting
|
|
33
|
+
when :memory, "memory" then MemoryStore.new(max_entries: config.max_entries, **options)
|
|
34
|
+
when :null, "null" then NullStore.new(**options)
|
|
35
|
+
when :redis, "redis"
|
|
36
|
+
RedisStore.new(url: config.redis_url, pool_size: config.redis_pool_size, **options)
|
|
37
|
+
when Store then setting
|
|
38
|
+
else
|
|
39
|
+
raise ConfigurationError, "unknown cache store #{setting.inspect}" unless setting.respond_to?(:fetch)
|
|
40
|
+
|
|
41
|
+
setting
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def cache
|
|
49
|
+
@cache ||= Cache.build
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_writer :cache
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
require_relative "cache/store"
|
|
57
|
+
require_relative "cache/memory_store"
|
|
58
|
+
require_relative "cache/null_store"
|
|
59
|
+
require_relative "cache/redis_store"
|
|
60
|
+
|
|
61
|
+
GemStack::Config.namespace(:cache, GemStack::Cache::Config)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# `gemstack add FEATURE` — opt-in modules, so apps that don't use them
|
|
6
|
+
# pay nothing: realtime, auth, storage.
|
|
7
|
+
class AddGenerator < Generator
|
|
8
|
+
FEATURES = %w[realtime auth storage].freeze
|
|
9
|
+
|
|
10
|
+
AUTH_ROUTES = <<~RUBY
|
|
11
|
+
# Authentication (gemstack add auth) — docs/authentication.md
|
|
12
|
+
post "/auth/signup", to: "registrations#create"
|
|
13
|
+
post "/auth/login", to: "sessions#create"
|
|
14
|
+
get "/auth/me", to: "sessions#show"
|
|
15
|
+
delete "/auth/logout", to: "sessions#destroy"
|
|
16
|
+
post "/auth/password/forgot", to: "password_resets#create"
|
|
17
|
+
post "/auth/password/reset", to: "password_resets#update"
|
|
18
|
+
post "/auth/email/resend", to: "email_verifications#create"
|
|
19
|
+
post "/auth/email/verify", to: "email_verifications#update"
|
|
20
|
+
get "/auth/tokens", to: "api_tokens#index"
|
|
21
|
+
post "/auth/tokens", to: "api_tokens#create"
|
|
22
|
+
delete "/auth/tokens/:id", to: "api_tokens#destroy"
|
|
23
|
+
RUBY
|
|
24
|
+
|
|
25
|
+
MAIL_ENV = <<~ENV
|
|
26
|
+
|
|
27
|
+
# Email. In development messages are saved to tmp/mail instead of being sent.
|
|
28
|
+
# SMTP_URL=smtp://user:password@smtp.example.com:587
|
|
29
|
+
# MAIL_FROM="My App <hello@example.com>"
|
|
30
|
+
# APP_URL=https://example.com # the frontend, for links in emails (default http://localhost:3000)
|
|
31
|
+
ENV
|
|
32
|
+
|
|
33
|
+
def initialize(feature, root:, output: $stdout, install: true)
|
|
34
|
+
super(output: output)
|
|
35
|
+
unless FEATURES.include?(feature)
|
|
36
|
+
raise Thor::Error,
|
|
37
|
+
"Unknown feature #{feature.inspect}. Available: #{FEATURES.join(", ")}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@feature = feature
|
|
41
|
+
@root = root
|
|
42
|
+
@install = install
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
public_send(:"add_#{@feature}")
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_realtime
|
|
51
|
+
add_gem("gemstack-realtime")
|
|
52
|
+
template_files("realtime", override_root: @root).each do |rel, source|
|
|
53
|
+
next if rel.start_with?("frontend/") && !File.directory?(File.join(@root, "frontend"))
|
|
54
|
+
|
|
55
|
+
write(File.join(@root, rel), File.read(source))
|
|
56
|
+
end
|
|
57
|
+
add_test_support('require "gemstack/realtime/testing"', "GemStack::Realtime::Testing")
|
|
58
|
+
bundle_install
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
STORAGE_ENV = <<~ENV
|
|
62
|
+
|
|
63
|
+
# File storage. Development and tests store files under storage/ (git-ignored).
|
|
64
|
+
# STORAGE_SERVICE=s3 # production default
|
|
65
|
+
# S3_BUCKET=my-app-uploads
|
|
66
|
+
# AWS_REGION=eu-west-3
|
|
67
|
+
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... (or an instance role)
|
|
68
|
+
# S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # S3-compatible services
|
|
69
|
+
ENV
|
|
70
|
+
|
|
71
|
+
def add_storage
|
|
72
|
+
enable_module("storage")
|
|
73
|
+
ensure_base_classes(@root, :serializer)
|
|
74
|
+
copy_feature_templates("storage")
|
|
75
|
+
add_routes(%(post "/uploads", to: "uploads#create" # direct uploads (gemstack add storage)\n),
|
|
76
|
+
marker: "uploads#create")
|
|
77
|
+
append_env_example(STORAGE_ENV, marker: "STORAGE_SERVICE")
|
|
78
|
+
append_line(".gitignore", "/storage/")
|
|
79
|
+
add_test_support('require "gemstack/storage/testing"', "GemStack::Storage::Testing")
|
|
80
|
+
status("note", "Gemfile", "add gem \"aws-sdk-s3\" for S3 in production (docs/storage.md)")
|
|
81
|
+
bundle_install
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# For templates: whether `gemstack add auth` ran.
|
|
85
|
+
def auth? = Generator.uses?(@root, :auth)
|
|
86
|
+
|
|
87
|
+
def add_auth
|
|
88
|
+
require_database!("auth")
|
|
89
|
+
user_model = File.join(@root, "app/models/user.rb")
|
|
90
|
+
if File.file?(user_model) && !File.read(user_model).include?("GemStack::Auth::User")
|
|
91
|
+
raise Thor::Error, "app/models/user.rb already exists. Add `include GemStack::Auth::User` to it and " \
|
|
92
|
+
"see docs/authentication.md (\"Existing users table\") instead."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
enable_module("jobs")
|
|
96
|
+
enable_module("mail")
|
|
97
|
+
add_gem("gemstack-auth")
|
|
98
|
+
JobGenerator.install_migration(@root, output: @output) # emails are sent from background jobs
|
|
99
|
+
ensure_base_classes(@root, :model, :serializer, :mailer)
|
|
100
|
+
copy_feature_templates("auth", skip_migration: "*_create_auth_tables.rb")
|
|
101
|
+
include_in_application_controller("GemStack::Auth::Controller")
|
|
102
|
+
add_routes(AUTH_ROUTES, marker: "/auth/login")
|
|
103
|
+
append_env_example(MAIL_ENV, marker: "SMTP_URL")
|
|
104
|
+
add_test_support('require "gemstack/mail/testing"', "GemStack::Mail::Testing")
|
|
105
|
+
add_test_support('require "gemstack/auth/testing"', "GemStack::Auth::Testing")
|
|
106
|
+
bundle_install
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def frontend? = File.directory?(File.join(@root, "frontend"))
|
|
112
|
+
|
|
113
|
+
def require_database!(feature)
|
|
114
|
+
return if Generator.uses?(@root, :db)
|
|
115
|
+
|
|
116
|
+
raise Thor::Error, "#{feature} stores its data in the database, and this app was created without one: " \
|
|
117
|
+
"add `require \"gemstack/db\"` to config/app.rb, a config/database.yml and a driver gem " \
|
|
118
|
+
"first (docs/database.md)"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Writes a feature's templates (frontend/ files only when the app has
|
|
122
|
+
# one). %timestamp% in a path becomes a fresh migration version, unless a
|
|
123
|
+
# migration matching skip_migration already exists.
|
|
124
|
+
def copy_feature_templates(name, skip_migration: nil)
|
|
125
|
+
has_migration = skip_migration && !Dir.glob(File.join(@root, "db/migrations", skip_migration)).empty?
|
|
126
|
+
template_files(name, override_root: @root).sort.each do |rel, source|
|
|
127
|
+
next if rel.start_with?("frontend/") && !frontend?
|
|
128
|
+
|
|
129
|
+
if rel.include?("%timestamp%")
|
|
130
|
+
next status("identical", rel, "migration already present") if has_migration
|
|
131
|
+
|
|
132
|
+
rel = rel.sub("%timestamp%", Generator.migration_timestamp(@root))
|
|
133
|
+
end
|
|
134
|
+
content = File.read(source)
|
|
135
|
+
content = render(content, source) if rel.end_with?(".tt")
|
|
136
|
+
write(File.join(@root, rel.delete_suffix(".tt")), content)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def include_in_application_controller(mixin)
|
|
141
|
+
path = File.join(@root, "app/controllers/application_controller.rb")
|
|
142
|
+
return status("skip", path, "not found — add `include #{mixin}` yourself") unless File.file?(path)
|
|
143
|
+
|
|
144
|
+
content = File.read(path)
|
|
145
|
+
return status("identical", path, mixin) if content.include?(mixin)
|
|
146
|
+
|
|
147
|
+
updated = content.sub(/^class ApplicationController < .*\n/) { |line| "#{line} include #{mixin}\n" }
|
|
148
|
+
return status("skip", path, "add `include #{mixin}` yourself") if updated == content
|
|
149
|
+
|
|
150
|
+
File.write(path, updated)
|
|
151
|
+
status("update", path, mixin)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def add_routes(block, marker:)
|
|
155
|
+
path = File.join(@root, "config/routes.rb")
|
|
156
|
+
content = File.file?(path) ? File.read(path) : ""
|
|
157
|
+
return status("identical", path, "routes") if content.include?(marker)
|
|
158
|
+
return status("skip", path, "no `GemStack.routes do` block — add the routes yourself") unless
|
|
159
|
+
content.match?(ControllerGenerator::ROUTES_BLOCK)
|
|
160
|
+
|
|
161
|
+
File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| open + block.gsub(/^/, " ") })
|
|
162
|
+
status("route", path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def append_line(file, line)
|
|
166
|
+
path = File.join(@root, file)
|
|
167
|
+
return unless File.file?(path)
|
|
168
|
+
|
|
169
|
+
content = File.read(path)
|
|
170
|
+
return if content.lines.map(&:strip).include?(line)
|
|
171
|
+
|
|
172
|
+
File.write(path, "#{content.rstrip}\n#{line}\n")
|
|
173
|
+
status("update", path, line)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def append_env_example(text, marker:)
|
|
177
|
+
path = File.join(@root, ".env.example")
|
|
178
|
+
return unless File.file?(path)
|
|
179
|
+
|
|
180
|
+
content = File.read(path)
|
|
181
|
+
return if content.include?(marker)
|
|
182
|
+
|
|
183
|
+
File.write(path, "#{content.rstrip}\n#{text}")
|
|
184
|
+
status("update", path)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Switches a module on in config/app.rb: uncomments its require line, or
|
|
188
|
+
# adds one after `require "gemstack"`. Apps from before 0.3.0 that list
|
|
189
|
+
# the module's gem in the Gemfile already have it.
|
|
190
|
+
def enable_module(name)
|
|
191
|
+
path = File.join(@root, "config/app.rb")
|
|
192
|
+
return status("identical", path, "gemstack/#{name}") if Generator.uses?(@root, name)
|
|
193
|
+
|
|
194
|
+
content = File.file?(path) ? File.read(path) : ""
|
|
195
|
+
line = %(require "gemstack/#{name}")
|
|
196
|
+
updated = content.sub(%r{^#\s*require "gemstack/#{name}".*$}, line)
|
|
197
|
+
updated = content.sub(/^require "gemstack"\n/) { |gemstack| "#{gemstack}#{line}\n" } if updated == content
|
|
198
|
+
return status("skip", path, "add `#{line}` to config/app.rb yourself") if updated == content
|
|
199
|
+
|
|
200
|
+
File.write(path, updated)
|
|
201
|
+
status("update", path, line)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Adds the gem inside the GemStack `path` block (checkout apps) or as a
|
|
205
|
+
# versioned dependency next to gem "gemstack".
|
|
206
|
+
def add_gem(name)
|
|
207
|
+
path = File.join(@root, "Gemfile")
|
|
208
|
+
content = File.read(path)
|
|
209
|
+
return status("identical", path, name) if content.match?(/^\s*gem "#{name}"/)
|
|
210
|
+
|
|
211
|
+
updated = content.match?(/^path ".*" do\n/) ? add_to_path_block(content, name) : add_versioned(content, name)
|
|
212
|
+
if updated == content
|
|
213
|
+
raise Thor::Error,
|
|
214
|
+
"couldn't find where to add #{name} in the Gemfile; add `gem \"#{name}\"` yourself"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
File.write(path, updated)
|
|
218
|
+
status("gemfile", path, name)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Keeps the block's gems in alphabetical order (Bundler/OrderedGems).
|
|
222
|
+
def add_to_path_block(content, name)
|
|
223
|
+
content.sub(/^(path ".*" do\n)((?: gem .*\n)*)/) do
|
|
224
|
+
opening = ::Regexp.last_match(1)
|
|
225
|
+
lines = ::Regexp.last_match(2) # before sort_by's regexes reset last_match
|
|
226
|
+
gems = (lines.lines + [" gem \"#{name}\"\n"]).sort_by { |line| line[/"([^"]+)"/, 1] }
|
|
227
|
+
opening + gems.join
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def add_versioned(content, name)
|
|
232
|
+
content.sub(/^gem "gemstack",.*\n/) { |line| "#{line}gem \"#{name}\", \"~> #{GemStack::VERSION}\"\n" }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def add_test_support(require_line, mixin)
|
|
236
|
+
path = File.join(@root, "test/test_helper.rb")
|
|
237
|
+
return unless File.file?(path)
|
|
238
|
+
|
|
239
|
+
content = File.read(path)
|
|
240
|
+
return status("identical", path) if content.include?(mixin)
|
|
241
|
+
|
|
242
|
+
content = content.sub(%(require "gemstack/testing"\n)) { |line| "#{line}#{require_line}\n" }
|
|
243
|
+
content = "#{content.rstrip}\nGemStack::TestCase.include #{mixin}\n"
|
|
244
|
+
File.write(path, content)
|
|
245
|
+
status("update", path, mixin)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def bundle_install
|
|
249
|
+
return unless @install
|
|
250
|
+
|
|
251
|
+
status("run", "bundle install")
|
|
252
|
+
install = -> { system("bundle", "install", "--quiet", chdir: @root) }
|
|
253
|
+
ok = defined?(Bundler) ? Bundler.with_unbundled_env(&install) : install.call
|
|
254
|
+
status("warning", "bundle install failed — run it yourself") unless ok
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|