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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
module HTTP
|
|
9
|
+
# JSON encoding/decoding behind a two-method interface (dump / load), so the
|
|
10
|
+
# implementation can be swapped with `config.http.json = :oj | codec`.
|
|
11
|
+
module JSONCodec
|
|
12
|
+
# How non-JSON-native Ruby objects are encoded. Objects must opt in by
|
|
13
|
+
# defining #as_json (or #to_h); anything else raises instead of silently
|
|
14
|
+
# leaking #inspect/#to_s output into API responses.
|
|
15
|
+
def self.coerce(object)
|
|
16
|
+
case object
|
|
17
|
+
when Symbol then object.name
|
|
18
|
+
when Time, DateTime then object.iso8601(3)
|
|
19
|
+
when Date then object.iso8601
|
|
20
|
+
when Set then object.to_a
|
|
21
|
+
else
|
|
22
|
+
if object.respond_to?(:as_json) then object.as_json
|
|
23
|
+
elsif defined?(BigDecimal) && object.is_a?(BigDecimal) then object.to_s("F")
|
|
24
|
+
elsif object.respond_to?(:to_h) then object.to_h
|
|
25
|
+
else
|
|
26
|
+
raise TypeError, "#{object.class} is not JSON serializable; define #as_json or use a serializer"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Default codec, backed by the json gem's JSON::Coder: native types are
|
|
32
|
+
# encoded in C and the coerce block only runs for other objects.
|
|
33
|
+
class Stdlib
|
|
34
|
+
def initialize(max_nesting: 64)
|
|
35
|
+
@max_nesting = max_nesting
|
|
36
|
+
@coder = ::JSON::Coder.new { |object| JSONCodec.coerce(object) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dump(object) = @coder.dump(object)
|
|
40
|
+
|
|
41
|
+
def load(source)
|
|
42
|
+
::JSON.parse(source, max_nesting: @max_nesting)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Oj
|
|
47
|
+
def initialize(max_nesting: 64)
|
|
48
|
+
require "oj"
|
|
49
|
+
@max_nesting = max_nesting
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Serializer output is already JSON-native, so try Oj's C strict mode
|
|
53
|
+
# first; only values with Time, BigDecimal, models... take the slower
|
|
54
|
+
# normalising path.
|
|
55
|
+
def dump(object)
|
|
56
|
+
::Oj.dump(object, mode: :strict)
|
|
57
|
+
rescue TypeError, EncodingError
|
|
58
|
+
::Oj.dump(normalize(object), mode: :strict)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load(source)
|
|
62
|
+
# Oj has no depth limit option in strict mode; enforce it with the
|
|
63
|
+
# stdlib parser's semantics by checking depth after parsing.
|
|
64
|
+
result = ::Oj.load(source, mode: :strict)
|
|
65
|
+
raise ::JSON::NestingError, "nesting too deep" if depth(result) > @max_nesting
|
|
66
|
+
|
|
67
|
+
result
|
|
68
|
+
rescue ::Oj::ParseError, EncodingError => e
|
|
69
|
+
raise ::JSON::ParserError, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize(object)
|
|
75
|
+
case object
|
|
76
|
+
when Hash then object.to_h { |k, v| [k.is_a?(Symbol) ? k.name : k, normalize(v)] }
|
|
77
|
+
when Array then object.map { |v| normalize(v) }
|
|
78
|
+
when String, Integer, Float, true, false, nil then object
|
|
79
|
+
else normalize(JSONCodec.coerce(object))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Same definition as the json gem: `{}` and `[]` have depth 1.
|
|
84
|
+
def depth(object)
|
|
85
|
+
case object
|
|
86
|
+
when Hash then 1 + (object.each_value.map { |v| depth(v) }.max || 0)
|
|
87
|
+
when Array then 1 + (object.map { |v| depth(v) }.max || 0)
|
|
88
|
+
else 0
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
BUILT_IN = { json: Stdlib, oj: Oj }.freeze
|
|
94
|
+
|
|
95
|
+
def self.resolve(setting, max_nesting: 64)
|
|
96
|
+
case setting
|
|
97
|
+
when Symbol, String
|
|
98
|
+
BUILT_IN.fetch(setting.to_sym) { raise ConfigurationError, "unknown JSON codec #{setting.inspect}" }
|
|
99
|
+
.new(max_nesting: max_nesting)
|
|
100
|
+
else
|
|
101
|
+
unless setting.respond_to?(:dump) && setting.respond_to?(:load)
|
|
102
|
+
raise ConfigurationError, "a JSON codec must respond to dump and load"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
setting
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.default
|
|
110
|
+
@default ||= Stdlib.new
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Rejects request bodies larger than config.http.max_body_size.
|
|
7
|
+
# Declared sizes (Content-Length) are rejected up front; bodies of
|
|
8
|
+
# unknown size (chunked) are counted while being read.
|
|
9
|
+
class BodyLimit
|
|
10
|
+
# Wraps rack.input and raises PayloadTooLarge once the limit is passed.
|
|
11
|
+
class LimitedInput
|
|
12
|
+
def initialize(input, limit)
|
|
13
|
+
@input = input
|
|
14
|
+
@limit = limit
|
|
15
|
+
@read = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(length = nil, buffer = nil)
|
|
19
|
+
data = @input.read(length, buffer)
|
|
20
|
+
count(data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def gets
|
|
24
|
+
count(@input.gets)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each
|
|
28
|
+
while (line = gets)
|
|
29
|
+
yield line
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rewind
|
|
34
|
+
@read = 0
|
|
35
|
+
@input.rewind
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close = @input.close
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def count(data)
|
|
43
|
+
@read += data.bytesize if data
|
|
44
|
+
raise PayloadTooLarge if @read > @limit
|
|
45
|
+
|
|
46
|
+
data
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(app, config)
|
|
51
|
+
@app = app
|
|
52
|
+
@limit = config.max_body_size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(env)
|
|
56
|
+
return @app.call(env) unless @limit
|
|
57
|
+
|
|
58
|
+
length = env["CONTENT_LENGTH"]
|
|
59
|
+
if length && !length.empty?
|
|
60
|
+
raise PayloadTooLarge if length.to_i > @limit
|
|
61
|
+
elsif (input = env[Rack::RACK_INPUT])
|
|
62
|
+
env[Rack::RACK_INPUT] = LimitedInput.new(input, @limit)
|
|
63
|
+
end
|
|
64
|
+
@app.call(env)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
module Middleware
|
|
8
|
+
# Response compression with content negotiation.
|
|
9
|
+
#
|
|
10
|
+
# - Negotiates Accept-Encoding (with q-values): Brotli when the `brotli`
|
|
11
|
+
# gem is available, otherwise gzip.
|
|
12
|
+
# - Compresses only compressible types (JSON, text, JS, XML, SVG) with a
|
|
13
|
+
# known body at least `min_size` bytes long.
|
|
14
|
+
# - Never touches: HEAD, 1xx/204/304, responses that already have a
|
|
15
|
+
# Content-Encoding, `Cache-Control: no-transform`, Server-Sent Events,
|
|
16
|
+
# or streamed bodies of unknown length.
|
|
17
|
+
# - Always sends `Vary: Accept-Encoding` for compressible types, and
|
|
18
|
+
# weakens strong ETags of compressed responses.
|
|
19
|
+
class Compression
|
|
20
|
+
COMPRESSIBLE = %r{
|
|
21
|
+
\A\s*(?:
|
|
22
|
+
text/(?!event-stream) # any text/*, except Server-Sent Events
|
|
23
|
+
| application/(?:json|javascript|xml|[\w.+-]+\+(?:json|xml)) # JSON, JS, XML and +json/+xml types
|
|
24
|
+
| image/svg\+xml
|
|
25
|
+
)
|
|
26
|
+
}xi
|
|
27
|
+
|
|
28
|
+
def self.brotli_available?
|
|
29
|
+
return @brotli_available if defined?(@brotli_available)
|
|
30
|
+
|
|
31
|
+
@brotli_available = begin
|
|
32
|
+
require "brotli"
|
|
33
|
+
true
|
|
34
|
+
rescue LoadError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(app, config)
|
|
40
|
+
@app = app
|
|
41
|
+
settings = config.compression
|
|
42
|
+
@enabled = settings.enabled
|
|
43
|
+
@min_size = settings.min_size
|
|
44
|
+
@gzip_level = settings.gzip_level
|
|
45
|
+
@brotli_quality = settings.brotli_quality
|
|
46
|
+
@encodings = settings.encodings.map(&:to_s).select do |encoding|
|
|
47
|
+
encoding == "gzip" || (encoding == "br" && self.class.brotli_available?)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(env)
|
|
52
|
+
status, headers, body = @app.call(env)
|
|
53
|
+
return [status, headers, body] unless @enabled && eligible?(env, status, headers)
|
|
54
|
+
|
|
55
|
+
vary(headers)
|
|
56
|
+
encoding = negotiate(env["HTTP_ACCEPT_ENCODING"])
|
|
57
|
+
return [status, headers, body] unless encoding && body.respond_to?(:to_ary)
|
|
58
|
+
|
|
59
|
+
content = read(body)
|
|
60
|
+
return [status, headers, [content]] if content.bytesize < @min_size
|
|
61
|
+
|
|
62
|
+
compressed = encode(encoding, content)
|
|
63
|
+
headers["content-encoding"] = encoding
|
|
64
|
+
headers["content-length"] = compressed.bytesize.to_s
|
|
65
|
+
headers["etag"] = "W/#{headers["etag"]}" if headers["etag"]&.start_with?('"')
|
|
66
|
+
[status, headers, [compressed]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# "gzip;q=0.5, br" → "br". Server preference order breaks ties; q=0 refuses.
|
|
70
|
+
def negotiate(header)
|
|
71
|
+
return nil if header.nil? || header.empty? || @encodings.empty?
|
|
72
|
+
|
|
73
|
+
accepted = header.split(",").to_h do |part|
|
|
74
|
+
name, *params = part.strip.split(";")
|
|
75
|
+
q = params.find { |p| p.strip.start_with?("q=") }&.then { |p| p.strip[2..].to_f } || 1.0
|
|
76
|
+
[name.to_s.strip.downcase, q]
|
|
77
|
+
end
|
|
78
|
+
wildcard = accepted.fetch("*", 0.0)
|
|
79
|
+
ranked = @encodings.map { |encoding| [encoding, accepted.fetch(encoding, wildcard)] }
|
|
80
|
+
best = ranked.select { |_, q| q.positive? }.max_by { |encoding, q| [q, -@encodings.index(encoding)] }
|
|
81
|
+
best&.first
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def eligible?(env, status, headers)
|
|
87
|
+
return false if env[Rack::REQUEST_METHOD] == "HEAD"
|
|
88
|
+
return false if status < 200 || status == 204 || status == 304
|
|
89
|
+
return false if headers["content-encoding"]
|
|
90
|
+
return false if headers["cache-control"]&.include?("no-transform")
|
|
91
|
+
|
|
92
|
+
COMPRESSIBLE.match?(headers["content-type"].to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def vary(headers)
|
|
96
|
+
current = headers["vary"]
|
|
97
|
+
return if current&.split(",")&.any? { |v| %w[accept-encoding *].include?(v.strip.downcase) }
|
|
98
|
+
|
|
99
|
+
headers["vary"] = current ? "#{current}, Accept-Encoding" : "Accept-Encoding"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read(body)
|
|
103
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
104
|
+
body.each { |part| content << part }
|
|
105
|
+
content
|
|
106
|
+
ensure
|
|
107
|
+
body.close if body.respond_to?(:close)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def encode(encoding, content)
|
|
111
|
+
case encoding
|
|
112
|
+
when "br" then Brotli.deflate(content, quality: @brotli_quality)
|
|
113
|
+
else gzip(content)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def gzip(content)
|
|
118
|
+
io = StringIO.new(String.new(encoding: Encoding::BINARY))
|
|
119
|
+
writer = Zlib::GzipWriter.new(io, @gzip_level)
|
|
120
|
+
writer.write(content)
|
|
121
|
+
writer.finish
|
|
122
|
+
io.string
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Cross-Origin Resource Sharing, driven by config.http.cors. It does
|
|
7
|
+
# nothing until origins are configured — same-origin GemStack apps
|
|
8
|
+
# (the default architecture) never need CORS.
|
|
9
|
+
class Cors
|
|
10
|
+
def initialize(app, config)
|
|
11
|
+
@app = app
|
|
12
|
+
cors = config.cors
|
|
13
|
+
@origins = Array(cors.origins)
|
|
14
|
+
@methods = cors.methods.map(&:upcase).join(", ")
|
|
15
|
+
@headers = cors.headers.join(", ")
|
|
16
|
+
@expose = cors.expose_headers.join(", ")
|
|
17
|
+
@credentials = cors.credentials
|
|
18
|
+
@max_age = cors.max_age.to_s
|
|
19
|
+
return unless @credentials && @origins.include?("*")
|
|
20
|
+
|
|
21
|
+
raise ConfigurationError, "CORS: credentials cannot be combined with the \"*\" origin"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(env)
|
|
25
|
+
origin = env["HTTP_ORIGIN"]
|
|
26
|
+
return @app.call(env) if @origins.empty? || origin.nil?
|
|
27
|
+
|
|
28
|
+
allowed = allowed?(origin)
|
|
29
|
+
return preflight(allowed ? origin : nil) if preflight?(env)
|
|
30
|
+
|
|
31
|
+
status, headers, body = @app.call(env)
|
|
32
|
+
vary(headers)
|
|
33
|
+
apply(headers, origin) if allowed
|
|
34
|
+
[status, headers, body]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def preflight?(env)
|
|
40
|
+
env[Rack::REQUEST_METHOD] == "OPTIONS" && env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preflight(origin)
|
|
44
|
+
headers = { "vary" => "Origin" }
|
|
45
|
+
if origin
|
|
46
|
+
apply(headers, origin)
|
|
47
|
+
headers["access-control-allow-methods"] = @methods
|
|
48
|
+
headers["access-control-allow-headers"] = @headers
|
|
49
|
+
headers["access-control-max-age"] = @max_age
|
|
50
|
+
end
|
|
51
|
+
[204, headers, []]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def apply(headers, origin)
|
|
55
|
+
headers["access-control-allow-origin"] = @origins.include?("*") ? "*" : origin
|
|
56
|
+
headers["access-control-allow-credentials"] = "true" if @credentials
|
|
57
|
+
headers["access-control-expose-headers"] = @expose unless @expose.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def vary(headers)
|
|
61
|
+
current = headers["vary"]
|
|
62
|
+
headers["vary"] = current ? "#{current}, Origin" : "Origin" unless current&.include?("Origin")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def allowed?(origin)
|
|
66
|
+
@origins.any? do |allowed|
|
|
67
|
+
case allowed
|
|
68
|
+
when "*" then true
|
|
69
|
+
when Regexp then allowed.match?(origin)
|
|
70
|
+
else allowed == origin
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# The exception boundary. Everything raised below it becomes a JSON
|
|
7
|
+
# error response; server errors are logged with their backtrace.
|
|
8
|
+
# Internals (class, message, backtrace) reach the client only when
|
|
9
|
+
# config.http.show_exceptions is on (development/test by default).
|
|
10
|
+
class ErrorHandler
|
|
11
|
+
def initialize(app, config)
|
|
12
|
+
@app = app
|
|
13
|
+
@show_exceptions = config.show_exceptions
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
@app.call(env)
|
|
18
|
+
rescue StandardError, ScriptError => e # ScriptError: syntax errors while reloading in development
|
|
19
|
+
response = ErrorRenderer.render(e, request_id: env[REQUEST_ID], show_exceptions: @show_exceptions)
|
|
20
|
+
return response if response[0] < 500
|
|
21
|
+
|
|
22
|
+
report(e, env)
|
|
23
|
+
# Development: a browser opening the URL gets a readable page.
|
|
24
|
+
@show_exceptions && ErrorPage.browser?(env) ? ErrorPage.render(e, env, request_id: env[REQUEST_ID]) : response
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def report(exception, env)
|
|
30
|
+
GemStack.logger.error(
|
|
31
|
+
"#{exception.class}: #{exception.message}",
|
|
32
|
+
id: env[REQUEST_ID],
|
|
33
|
+
backtrace: Array(exception.backtrace).first(ErrorRenderer::BACKTRACE_LINES)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# ETags and conditional GET, using Rack's own middleware: Rack::ETag adds
|
|
7
|
+
# a weak ETag (SHA-256 of the body) to buffered 200/201 responses that
|
|
8
|
+
# don't set one, and Rack::ConditionalGet answers matching
|
|
9
|
+
# If-None-Match / If-Modified-Since requests with 304 and no body.
|
|
10
|
+
# Controllers can set ETags themselves (see Controller#stale?).
|
|
11
|
+
#
|
|
12
|
+
# Responses without Cache-Control get Rack's
|
|
13
|
+
# "max-age=0, private, must-revalidate": clients may keep a copy but
|
|
14
|
+
# must revalidate it — the safe default for API data.
|
|
15
|
+
class ETags
|
|
16
|
+
def initialize(app, config)
|
|
17
|
+
@app = config.etags ? Rack::ConditionalGet.new(Rack::ETag.new(app)) : app
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env) = @app.call(env)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Answers GET/HEAD config.http.health_path with 200 {"status":"ok"}
|
|
7
|
+
# without touching the router.
|
|
8
|
+
class HealthCheck
|
|
9
|
+
BODY = '{"status":"ok"}'
|
|
10
|
+
|
|
11
|
+
def initialize(app, config)
|
|
12
|
+
@app = app
|
|
13
|
+
@path = config.health_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
return @app.call(env) unless @path && env[Rack::PATH_INFO] == @path
|
|
18
|
+
|
|
19
|
+
method = env[Rack::REQUEST_METHOD]
|
|
20
|
+
return @app.call(env) unless %w[GET HEAD].include?(method)
|
|
21
|
+
|
|
22
|
+
headers = { "content-type" => "application/json", "cache-control" => "no-store" }
|
|
23
|
+
[200, headers, method == "HEAD" ? [] : [BODY]]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
module Middleware
|
|
8
|
+
# Assigns every request an id (env["gemstack.request_id"]), echoed in the
|
|
9
|
+
# X-Request-Id response header and included in logs and error bodies.
|
|
10
|
+
# An incoming X-Request-Id (e.g. from a load balancer) is reused when
|
|
11
|
+
# trusted and well-formed.
|
|
12
|
+
class RequestId
|
|
13
|
+
VALID = /\A[A-Za-z0-9\-_.:]{1,128}\z/
|
|
14
|
+
|
|
15
|
+
def initialize(app, config)
|
|
16
|
+
@app = app
|
|
17
|
+
@trust = config.trust_request_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
incoming = env["HTTP_X_REQUEST_ID"]
|
|
22
|
+
id = @trust && incoming&.match?(VALID) ? incoming : SecureRandom.uuid
|
|
23
|
+
env[REQUEST_ID] = id
|
|
24
|
+
status, headers, body = @app.call(env)
|
|
25
|
+
headers["x-request-id"] = id
|
|
26
|
+
[status, headers, body]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# One structured log line per request, written when the response body is
|
|
7
|
+
# closed so streamed responses are timed correctly:
|
|
8
|
+
#
|
|
9
|
+
# INFO GET /api/products status=200 ms=1.84 id=5f0c...
|
|
10
|
+
#
|
|
11
|
+
# Query strings are not logged (they often carry tokens).
|
|
12
|
+
class RequestLogger
|
|
13
|
+
def initialize(app, logger: nil)
|
|
14
|
+
@app = app
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
status, headers, body = @app.call(env)
|
|
21
|
+
body = Rack::BodyProxy.new(body) { log(env, status, started) }
|
|
22
|
+
[status, headers, body]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def log(env, status, started)
|
|
28
|
+
logger = @logger || GemStack.logger
|
|
29
|
+
return unless logger.info?
|
|
30
|
+
|
|
31
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
32
|
+
severity = status >= 500 ? :error : :info
|
|
33
|
+
logger.public_send(severity, "#{env[Rack::REQUEST_METHOD]} #{env[Rack::PATH_INFO]}",
|
|
34
|
+
status: status, ms: ms, id: env[REQUEST_ID])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Adds config.http.security_headers to responses that don't already set
|
|
7
|
+
# them, plus Strict-Transport-Security on HTTPS requests when
|
|
8
|
+
# config.http.hsts is set (production by default).
|
|
9
|
+
class SecurityHeaders
|
|
10
|
+
def initialize(app, config)
|
|
11
|
+
@app = app
|
|
12
|
+
@headers = config.security_headers.transform_keys { |key| key.to_s.downcase }.freeze
|
|
13
|
+
@hsts = config.hsts
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
status, headers, body = @app.call(env)
|
|
18
|
+
@headers.each { |key, value| headers[key] = value unless headers.key?(key) }
|
|
19
|
+
headers["strict-transport-security"] ||= @hsts if @hsts && https?(env)
|
|
20
|
+
[status, headers, body]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def https?(env)
|
|
26
|
+
env[Rack::RACK_URL_SCHEME] == "https" || env["HTTP_X_FORWARDED_PROTO"]&.start_with?("https")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
# An ordered, editable list of Rack middleware, compiled once into a
|
|
6
|
+
# nested Rack app when the application is built.
|
|
7
|
+
#
|
|
8
|
+
# config.http.middleware.use Rack::Attack
|
|
9
|
+
# config.http.middleware.insert_before GemStack::HTTP::Middleware::ErrorHandler, MyTiming
|
|
10
|
+
# config.http.middleware.swap GemStack::HTTP::Middleware::RequestLogger, MyLogger
|
|
11
|
+
# config.http.middleware.delete GemStack::HTTP::Middleware::SecurityHeaders
|
|
12
|
+
#
|
|
13
|
+
# Targets are middleware classes or integer positions. Any Rack
|
|
14
|
+
# middleware (`new(app, *args)`, `#call(env)`) works.
|
|
15
|
+
class MiddlewareStack
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
Entry = Struct.new(:klass, :args, :kwargs, :block) do
|
|
19
|
+
def build(app) = klass.new(app, *args, **kwargs, &block)
|
|
20
|
+
def name = klass.respond_to?(:name) && klass.name ? klass.name : klass.inspect
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.default(config)
|
|
24
|
+
new.tap do |stack|
|
|
25
|
+
stack.use Middleware::RequestId, config
|
|
26
|
+
stack.use Middleware::RequestLogger
|
|
27
|
+
stack.use Middleware::Compression, config
|
|
28
|
+
stack.use Middleware::ErrorHandler, config
|
|
29
|
+
stack.use Middleware::SecurityHeaders, config
|
|
30
|
+
stack.use Middleware::Cors, config
|
|
31
|
+
stack.use Middleware::BodyLimit, config
|
|
32
|
+
stack.use Middleware::HealthCheck, config
|
|
33
|
+
stack.use Middleware::ETags, config
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@entries = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def use(klass, *args, **kwargs, &block)
|
|
42
|
+
@entries << Entry.new(klass, args, kwargs, block)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unshift(klass, *args, **kwargs, &block)
|
|
47
|
+
@entries.unshift(Entry.new(klass, args, kwargs, block))
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def insert_before(target, klass, *args, **kwargs, &block)
|
|
52
|
+
@entries.insert(index!(target), Entry.new(klass, args, kwargs, block))
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def insert_after(target, klass, *args, **kwargs, &block)
|
|
57
|
+
@entries.insert(index!(target) + 1, Entry.new(klass, args, kwargs, block))
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def swap(target, klass, *args, **kwargs, &block)
|
|
62
|
+
@entries[index!(target)] = Entry.new(klass, args, kwargs, block)
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete(target)
|
|
67
|
+
@entries.delete_at(index!(target))
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def include?(klass) = @entries.any? { |entry| entry.klass == klass }
|
|
72
|
+
def each(&) = @entries.each(&)
|
|
73
|
+
def size = @entries.size
|
|
74
|
+
def names = @entries.map(&:name)
|
|
75
|
+
|
|
76
|
+
# Wraps endpoint so that the first middleware in the list runs first.
|
|
77
|
+
def build(endpoint)
|
|
78
|
+
@entries.reverse.inject(endpoint) { |app, entry| entry.build(app) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def initialize_copy(source)
|
|
82
|
+
super
|
|
83
|
+
@entries = source.to_a.dup
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def index!(target)
|
|
89
|
+
index = target.is_a?(Integer) ? target : @entries.index { |entry| entry.klass == target }
|
|
90
|
+
raise ArgumentError, "no middleware #{target.inspect} in the stack" unless index && @entries[index]
|
|
91
|
+
|
|
92
|
+
index
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|