rubst_api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/SECURITY.md ADDED
@@ -0,0 +1,29 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ RubstAPI is currently in initial development. Security fixes are applied to the
6
+ latest released version.
7
+
8
+ | Version | Supported |
9
+ | --- | --- |
10
+ | 0.1.x | Yes |
11
+ | Earlier versions | No |
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ Please do not report suspected vulnerabilities in a public GitHub issue.
16
+
17
+ Use GitHub's private vulnerability reporting for
18
+ [joryleech/RubstApi](https://github.com/joryleech/RubstApi/security/advisories/new).
19
+ Include:
20
+
21
+ - the affected RubstAPI version;
22
+ - a minimal reproduction;
23
+ - the expected and observed behavior;
24
+ - the potential impact;
25
+ - any known mitigation.
26
+
27
+ Please allow a reasonable period for investigation and remediation before
28
+ public disclosure. Receipt will be acknowledged through GitHub when private
29
+ vulnerability reporting is available for the repository.
@@ -0,0 +1,20 @@
1
+ # Third-Party Notices
2
+
3
+ RubstAPI is an independent Ruby implementation inspired by the design and
4
+ developer experience of the Python FastAPI project.
5
+
6
+ ## FastAPI
7
+
8
+ - Project: FastAPI
9
+ - Source: <https://github.com/fastapi/fastapi>
10
+ - Copyright: Copyright (c) 2018 Sebastián Ramírez
11
+ - License: MIT
12
+ - Included license: [LICENSES/FASTAPI-MIT.txt](LICENSES/FASTAPI-MIT.txt)
13
+
14
+ The original FastAPI MIT license and copyright notice are reproduced verbatim
15
+ and distributed with this project. FastAPI is not bundled as a runtime
16
+ dependency. Inclusion of its license does not imply endorsement, affiliation,
17
+ or trademark permission.
18
+
19
+ RubstAPI's own source code is distributed under the separate project license
20
+ in [LICENSE](LICENSE).
data/exe/rubst_api ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "rubst_api"
6
+
7
+ options = { host: "127.0.0.1", port: 8000 }
8
+ command = ARGV.shift || "help"
9
+ OptionParser.new do |parser|
10
+ parser.on("--host HOST") { |value| options[:host] = value }
11
+ parser.on("--port PORT", Integer) { |value| options[:port] = value }
12
+ end.parse!
13
+
14
+ if %w[run dev].include?(command)
15
+ file = ARGV.shift || "app.rb"
16
+ require File.expand_path(file)
17
+ app = Object.const_defined?(:APP) ? APP : Object.const_defined?(:App) ? App : nil
18
+ abort "Define APP = RubstApi::App.new in #{file}" unless app
19
+ begin
20
+ require "rack"
21
+ require "rackup"
22
+ handler = Rackup::Handler.get("webrick")
23
+ handler.run(app, Host: options[:host], Port: options[:port])
24
+ rescue LoadError
25
+ abort "The server command requires the optional rack and rackup gems."
26
+ end
27
+ else
28
+ puts "Usage: rubst_api [dev|run] [app.rb] [--host HOST] [--port PORT]"
29
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubstApi
4
+ class App < APIRouter
5
+ attr_reader :title, :description, :version, :openapi_url, :docs_url, :redoc_url,
6
+ :openapi_version, :servers, :openapi_tags, :dependency_overrides
7
+
8
+ def initialize(title: "RubstAPI", description: nil, version: "0.1.0", openapi_url: "/openapi.json",
9
+ docs_url: "/docs", redoc_url: "/redoc", openapi_version: "3.1.0",
10
+ servers: [], openapi_tags: [], dependencies: [], **router_options)
11
+ super(dependencies:, **router_options)
12
+ @title, @description, @version = title, description, version
13
+ @openapi_url, @docs_url, @redoc_url, @openapi_version = openapi_url, docs_url, redoc_url, openapi_version
14
+ @servers, @openapi_tags, @dependency_overrides = servers, openapi_tags, {}
15
+ @exception_handlers, @user_middleware, @startup_handlers, @shutdown_handlers = {}, [], [], []
16
+ @mounts = []
17
+ @openapi_schema = nil
18
+ install_default_handlers
19
+ end
20
+
21
+ def call(env)
22
+ build_stack.call(env)
23
+ rescue StandardError => error
24
+ handle_exception(error)
25
+ end
26
+
27
+ def route_request(env)
28
+ request_path, request_method = env.fetch("PATH_INFO", "/"), env.fetch("REQUEST_METHOD", "GET").upcase
29
+ if (mounted = @mounts.find { |prefix, _| request_path == prefix || request_path.start_with?("#{prefix}/") })
30
+ prefix, application = mounted
31
+ child_path = request_path.delete_prefix(prefix)
32
+ child_env = env.merge("SCRIPT_NAME" => "#{env["SCRIPT_NAME"]}#{prefix}",
33
+ "PATH_INFO" => child_path.empty? ? "/" : child_path)
34
+ return application.call(child_env)
35
+ end
36
+ return JSONResponse.new(openapi).finish if openapi_url && request_path == openapi_url
37
+ return HTMLResponse.new(swagger_html).finish if docs_url && request_path == docs_url
38
+ return HTMLResponse.new(redoc_html).finish if redoc_url && request_path == redoc_url
39
+
40
+ path_matches = routes.filter_map { |route| [route, route.match(request_path)] if route.match(request_path) }
41
+ raise HTTPException.new(status_code: 404) if path_matches.empty?
42
+ route, captures = path_matches.find { |candidate, _| candidate.allowed?(request_method) }
43
+ unless route
44
+ allowed = path_matches.flat_map { |candidate, _| candidate.methods }.uniq.join(", ")
45
+ raise HTTPException.new(status_code: 405, headers: { "Allow" => allowed })
46
+ end
47
+ request = Request.new(env, path_params: captures)
48
+ execute_route(route, request)
49
+ end
50
+
51
+ def execute_route(route, request)
52
+ resolver = DependencyResolver.new(request, dependency_overrides)
53
+ values = {}
54
+ errors = []
55
+ body_parameter_count = route.params.values.count { |value| value.is_a?(Param) && value.location == :body }
56
+ route.dependencies.each { |dependency| resolver.resolve(dependency) }
57
+ route.params.each do |name, declaration|
58
+ if declaration.is_a?(Dependency)
59
+ values[name] = resolver.resolve(declaration)
60
+ next
61
+ end
62
+ raw = extract_parameter(name, declaration, request, body_parameter_count:)
63
+ if raw.equal?(UNDEFINED)
64
+ if declaration.required?
65
+ errors << { type: "missing", loc: [declaration.location, declaration.name_for(name)], msg: "Field required", input: nil }
66
+ else
67
+ values[name] = declaration.default.equal?(UNDEFINED) ? nil : declaration.default
68
+ end
69
+ next
70
+ end
71
+ begin
72
+ values[name] = Validator.coerce(raw, declaration.type, declaration.constraints)
73
+ rescue ValidationError => error
74
+ errors.concat(error.errors.map { |item| item.merge(loc: [declaration.location, declaration.name_for(name)] + Array(item[:loc])) })
75
+ end
76
+ end
77
+ raise RequestValidationError.new(errors, body: request.body) unless errors.empty?
78
+ inject_special_arguments(route.endpoint, values, request)
79
+ result = invoke(route.endpoint, values)
80
+ response = prepare_response(result, route)
81
+ if (injected = values[:response])
82
+ response.status_code = injected.status_code if injected.status_code != 200
83
+ injected.headers.each { |key, value| response.headers[key] = value }
84
+ end
85
+ resolver.close
86
+ finished = response.finish
87
+ values[:background_tasks]&.call
88
+ finished
89
+ ensure
90
+ resolver&.close
91
+ end
92
+
93
+ def openapi
94
+ @openapi_schema ||= OpenAPI.generate(self)
95
+ end
96
+
97
+ def add_api_route(...)
98
+ @openapi_schema = nil
99
+ super
100
+ end
101
+
102
+ def include_router(...)
103
+ @openapi_schema = nil
104
+ super
105
+ end
106
+
107
+ def add_middleware(middleware_class, **options)
108
+ @user_middleware << [middleware_class, options]
109
+ @middleware_stack = nil
110
+ self
111
+ end
112
+
113
+ def exception_handler(exception_class, callable = nil, &block)
114
+ @exception_handlers[exception_class] = callable || block
115
+ end
116
+
117
+ def on_event(event, callable = nil, &block)
118
+ handlers = event.to_sym == :startup ? @startup_handlers : @shutdown_handlers
119
+ handlers << (callable || block)
120
+ end
121
+
122
+ def startup = @startup_handlers.each(&:call)
123
+ def shutdown = @shutdown_handlers.reverse_each(&:call)
124
+
125
+ def mount(path, application, name: nil)
126
+ prefix = path.end_with?("/") ? path.chomp("/") : path
127
+ @mounts << [prefix, application]
128
+ application
129
+ end
130
+
131
+ private
132
+
133
+ def build_stack
134
+ @middleware_stack ||= @user_middleware.reverse.inject(method(:route_request)) do |inner, (klass, options)|
135
+ klass.new(inner, **options)
136
+ end
137
+ end
138
+
139
+ def install_default_handlers
140
+ exception_handler(HTTPException) do |error|
141
+ JSONResponse.new({ detail: error.detail }, status_code: error.status_code, headers: error.headers)
142
+ end
143
+ exception_handler(RequestValidationError) do |error|
144
+ JSONResponse.new({ detail: error.errors }, status_code: 422)
145
+ end
146
+ exception_handler(ResponseValidationError) do |error|
147
+ JSONResponse.new({ detail: error.errors }, status_code: 500)
148
+ end
149
+ end
150
+
151
+ def handle_exception(error)
152
+ pair = @exception_handlers.find { |klass, _| error.is_a?(klass) }
153
+ raise error unless pair
154
+ response = pair.last.call(error)
155
+ response = JSONResponse.new(response) unless response.is_a?(Response)
156
+ response.finish
157
+ end
158
+
159
+ def extract_parameter(name, param, request, body_parameter_count: 0)
160
+ key = param.name_for(name)
161
+ value = case param.location
162
+ when :path then request.path_params[key]
163
+ when :query then request.query_params[key]
164
+ when :header then request.headers[key.tr("_", "-")]
165
+ when :cookie then request.cookies[key]
166
+ when :body then extract_body(name, param, request, embedded: body_parameter_count > 1)
167
+ when :form then form_data(request)[key]
168
+ when :file then multipart_data(request)[key]
169
+ end
170
+ value.nil? ? UNDEFINED : value
171
+ end
172
+
173
+ def extract_body(name, param, request, embedded: false)
174
+ parsed = request.json
175
+ return parsed unless embedded && parsed.is_a?(Hash)
176
+ parsed.fetch(param.name_for(name)) { parsed.fetch(param.name_for(name).to_sym, UNDEFINED) }
177
+ rescue JSON::ParserError
178
+ raise RequestValidationError.new([{ type: "json_invalid", loc: [:body], msg: "JSON decode error", input: request.body }])
179
+ end
180
+
181
+ def route_like_model?(type) = type.is_a?(Class) && type <= Model
182
+
183
+ def form_data(request)
184
+ @form_cache ||= {}
185
+ @form_cache[request.object_id] ||= URI.decode_www_form(request.body).to_h
186
+ end
187
+
188
+ def multipart_data(request)
189
+ boundary = request.headers["content-type"].to_s[/boundary=(?:"([^"]+)"|([^;]+))/, 1] ||
190
+ request.headers["content-type"].to_s[/boundary=(?:"([^"]+)"|([^;]+))/, 2]
191
+ return {} unless boundary
192
+ request.body.split("--#{boundary}").each_with_object({}) do |part, output|
193
+ head, content = part.split("\r\n\r\n", 2)
194
+ next unless head && content
195
+ name = head[/name="([^"]+)"/, 1]
196
+ next unless name
197
+ content = content.sub(/\r\n\z/, "")
198
+ filename = head[/filename="([^"]*)"/, 1]
199
+ output[name] = filename ? UploadFile.new(filename:, io: StringIO.new(content),
200
+ content_type: head[/Content-Type:\s*([^\r\n]+)/i, 1] || "application/octet-stream") : content
201
+ end
202
+ end
203
+
204
+ def inject_special_arguments(callable, values, request)
205
+ names = callable.parameters.map(&:last)
206
+ values[:request] = request if names.include?(:request) && !values.key?(:request)
207
+ values[:response] = Response.new if names.include?(:response) && !values.key?(:response)
208
+ values[:background_tasks] = BackgroundTasks.new if names.include?(:background_tasks) && !values.key?(:background_tasks)
209
+ values[:websocket] = WebSocket.new(request.env, path_params: request.path_params) if names.include?(:websocket) && !values.key?(:websocket)
210
+ end
211
+
212
+ def invoke(callable, values)
213
+ parameters = callable.parameters
214
+ if parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
215
+ callable.call(**values)
216
+ elsif parameters.empty?
217
+ callable.call
218
+ else
219
+ callable.call(*parameters.map { |_, name| values[name] })
220
+ end
221
+ end
222
+
223
+ def prepare_response(result, route)
224
+ return result if result.is_a?(Response)
225
+ if route.response_model
226
+ begin
227
+ result = Validator.coerce(result, route.response_model, {})
228
+ rescue ValidationError => error
229
+ raise ResponseValidationError, error.errors
230
+ end
231
+ end
232
+ JSONResponse.new(result, status_code: route.status_code)
233
+ end
234
+
235
+ def swagger_html
236
+ <<~HTML
237
+ <!doctype html><html><head><title>#{title} - Swagger UI</title>
238
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"></head>
239
+ <body><div id="swagger-ui"></div><script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
240
+ <script>SwaggerUIBundle({url: #{openapi_url.to_json}, dom_id: '#swagger-ui', deepLinking: true})</script></body></html>
241
+ HTML
242
+ end
243
+
244
+ def redoc_html
245
+ <<~HTML
246
+ <!doctype html><html><head><title>#{title} - ReDoc</title></head>
247
+ <body><redoc spec-url="#{openapi_url}"></redoc>
248
+ <script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"></script></body></html>
249
+ HTML
250
+ end
251
+ end
252
+
253
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubstApi
4
+ class BackgroundTask
5
+ def initialize(callable = nil, *args, **kwargs, &block)
6
+ @callable, @args, @kwargs = callable || block, args, kwargs
7
+ end
8
+ def call = @callable.call(*@args, **@kwargs)
9
+ end
10
+
11
+ class BackgroundTasks
12
+ def initialize = @tasks = []
13
+ def add_task(callable = nil, *args, **kwargs, &block)
14
+ @tasks << BackgroundTask.new(callable, *args, **kwargs, &block)
15
+ end
16
+ def call = @tasks.each(&:call)
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubstApi
4
+ class Dependency
5
+ attr_reader :callable, :use_cache, :dependencies
6
+ def initialize(callable, use_cache: true, dependencies: {})
7
+ raise ArgumentError, "dependency must be callable" unless callable.respond_to?(:call)
8
+ @callable, @use_cache, @dependencies = callable, use_cache, dependencies
9
+ end
10
+ end
11
+
12
+ class DependencyResolver
13
+ def initialize(request, overrides = {})
14
+ @request, @overrides, @cache, @cleanups = request, overrides, {}, []
15
+ end
16
+
17
+ def resolve(dependency)
18
+ callable = @overrides.fetch(dependency.callable, dependency.callable)
19
+ return @cache[callable] if dependency.use_cache && @cache.key?(callable)
20
+ signature = callable.respond_to?(:parameters) ? callable : callable.method(:call)
21
+ arguments = dependency.dependencies.to_h { |name, child| [name, resolve(child)] }
22
+ signature.parameters.each do |kind, name|
23
+ next unless %i[key keyreq].include?(kind)
24
+ arguments[name] = @request if name == :request
25
+ end
26
+ value = callable.call(**arguments)
27
+ value = consume_generator(value) if value.is_a?(Enumerator)
28
+ @cache[callable] = value if dependency.use_cache
29
+ value
30
+ end
31
+
32
+ def close
33
+ return if @closed
34
+ @closed = true
35
+ @cleanups.reverse_each(&:call)
36
+ end
37
+
38
+ private
39
+
40
+ def consume_generator(generator)
41
+ value = generator.next
42
+ @cleanups << proc { generator.next rescue StopIteration }
43
+ value
44
+ end
45
+ end
46
+
47
+ class SecurityDependency < Dependency
48
+ attr_reader :scopes
49
+ def initialize(callable, scopes: [], **options)
50
+ super(callable, **options)
51
+ @scopes = scopes
52
+ end
53
+ end
54
+
55
+ def self.Depends(...) = RubstApi.Depends(...)
56
+ def self.Security(...) = RubstApi.Security(...)
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubstApi
4
+ class HTTPException < StandardError
5
+ attr_reader :status_code, :detail, :headers
6
+
7
+ def initialize(status_code:, detail: nil, headers: {})
8
+ @status_code = status_code
9
+ @detail = detail || default_detail(status_code)
10
+ @headers = headers
11
+ super(@detail.to_s)
12
+ end
13
+
14
+ private
15
+
16
+ def default_detail(code)
17
+ { 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden",
18
+ 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
19
+ 422 => "Unprocessable Entity", 500 => "Internal Server Error" }[code] || "HTTP Error"
20
+ end
21
+ end
22
+
23
+ class WebSocketException < StandardError
24
+ attr_reader :code, :reason
25
+ def initialize(code:, reason: nil)
26
+ @code, @reason = code, reason
27
+ super(reason)
28
+ end
29
+ end
30
+
31
+ class RequestValidationError < StandardError
32
+ attr_reader :errors, :body
33
+ def initialize(errors, body: nil)
34
+ @errors, @body = errors, body
35
+ super("Request validation failed")
36
+ end
37
+ end
38
+
39
+ class ResponseValidationError < StandardError
40
+ attr_reader :errors
41
+ def initialize(errors)
42
+ @errors = errors
43
+ super("Response validation failed")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+ require "uri"
6
+
7
+ module RubstApi
8
+ class Headers
9
+ include Enumerable
10
+ def initialize(values = {})
11
+ @values = values.to_h { |key, value| [key.to_s.downcase.tr("_", "-"), value.to_s] }
12
+ end
13
+ def [](name) = @values[name.to_s.downcase.tr("_", "-")]
14
+ def []=(name, value)
15
+ @values[name.to_s.downcase.tr("_", "-")] = value.to_s
16
+ end
17
+ def each(&) = @values.each(&)
18
+ def to_h = @values.dup
19
+ end
20
+
21
+ class Request
22
+ attr_reader :env, :path_params, :state
23
+ def initialize(env, path_params: {})
24
+ @env, @path_params, @state = env, path_params, {}
25
+ end
26
+ def method = env.fetch("REQUEST_METHOD", "GET").upcase
27
+ def path = env.fetch("PATH_INFO", "/")
28
+ def query_string = env.fetch("QUERY_STRING", "")
29
+ def query_params = @query_params ||= URI.decode_www_form(query_string).each_with_object({}) { |(k, v), h| h[k] = h.key?(k) ? Array(h[k]) << v : v }
30
+ def headers
31
+ @headers ||= Headers.new(env.filter_map do |key, value|
32
+ next unless key.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
33
+ [key.sub(/\AHTTP_/, "").downcase, value]
34
+ end.to_h)
35
+ end
36
+ def cookies
37
+ @cookies ||= (headers["cookie"] || "").split(/;\s*/).filter_map { |pair| pair.split("=", 2) if pair.include?("=") }.to_h
38
+ end
39
+ def body
40
+ @body ||= begin
41
+ io = env["rack.input"] || StringIO.new
42
+ value = io.read
43
+ io.rewind if io.respond_to?(:rewind)
44
+ value
45
+ end
46
+ end
47
+ def json = body.empty? ? nil : JSON.parse(body)
48
+ def content_type = headers["content-type"]&.split(";")&.first
49
+ def client = [env["REMOTE_ADDR"], env["REMOTE_PORT"]]
50
+ def url = "#{env["rack.url_scheme"] || "http"}://#{env["HTTP_HOST"] || "localhost"}#{path}#{query_string.empty? ? "" : "?#{query_string}"}"
51
+ end
52
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubstApi
4
+ module Middleware
5
+ class CORSMiddleware
6
+ def initialize(app, allow_origins: [], allow_methods: ["GET"], allow_headers: [], allow_credentials: false,
7
+ expose_headers: [], max_age: 600, allow_origin_regex: nil)
8
+ @app, @origins, @methods, @headers = app, allow_origins, allow_methods, allow_headers
9
+ @credentials, @expose, @max_age, @origin_regex = allow_credentials, expose_headers, max_age, allow_origin_regex
10
+ end
11
+ def call(env)
12
+ origin = env["HTTP_ORIGIN"]
13
+ return @app.call(env) unless origin
14
+ allowed = @origins.include?("*") || @origins.include?(origin) || (@origin_regex && Regexp.new(@origin_regex).match?(origin))
15
+ return @app.call(env) unless allowed
16
+ cors = {
17
+ "access-control-allow-origin" => @credentials ? origin : (@origins.include?("*") ? "*" : origin),
18
+ "vary" => "Origin"
19
+ }
20
+ if env["REQUEST_METHOD"] == "OPTIONS" && env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"]
21
+ cors.merge!("access-control-allow-methods" => @methods.join(", "),
22
+ "access-control-allow-headers" => @headers.join(", "),
23
+ "access-control-max-age" => @max_age.to_s)
24
+ cors["access-control-allow-credentials"] = "true" if @credentials
25
+ [200, cors.merge("content-length" => "2"), ["OK"]]
26
+ else
27
+ status, headers, body = @app.call(env)
28
+ headers.merge!(cors)
29
+ headers["access-control-expose-headers"] = @expose.join(", ") unless @expose.empty?
30
+ headers["access-control-allow-credentials"] = "true" if @credentials
31
+ [status, headers, body]
32
+ end
33
+ end
34
+ end
35
+
36
+ class GZipMiddleware
37
+ def initialize(app, minimum_size: 500, compresslevel: 9)
38
+ @app, @minimum_size, @level = app, minimum_size, compresslevel
39
+ end
40
+ def call(env)
41
+ status, headers, body = @app.call(env)
42
+ content = body.respond_to?(:each) ? body.to_a.join : body.to_s
43
+ return [status, headers, [content]] unless env["HTTP_ACCEPT_ENCODING"].to_s.include?("gzip") && content.bytesize >= @minimum_size
44
+ require "zlib"
45
+ require "stringio"
46
+ io = StringIO.new
47
+ writer = Zlib::GzipWriter.new(io, @level)
48
+ writer.write(content)
49
+ writer.close
50
+ headers = headers.merge("content-encoding" => "gzip", "content-length" => io.string.bytesize.to_s, "vary" => "Accept-Encoding")
51
+ [status, headers, [io.string]]
52
+ end
53
+ end
54
+
55
+ class TrustedHostMiddleware
56
+ def initialize(app, allowed_hosts: ["*"], www_redirect: true)
57
+ @app, @hosts, @redirect = app, allowed_hosts, www_redirect
58
+ end
59
+ def call(env)
60
+ host = env["HTTP_HOST"].to_s.split(":").first
61
+ allowed = @hosts.include?("*") || @hosts.any? { |pattern| pattern.start_with?("*.") ? host.end_with?(pattern.delete_prefix("*")) : host == pattern }
62
+ return PlainTextResponse.new("Invalid host header", status_code: 400).finish unless allowed
63
+ @app.call(env)
64
+ end
65
+ end
66
+
67
+ class HTTPSRedirectMiddleware
68
+ def initialize(app) = @app = app
69
+ def call(env)
70
+ return @app.call(env) if env["rack.url_scheme"] == "https"
71
+ host, path, query = env["HTTP_HOST"], env["PATH_INFO"], env["QUERY_STRING"]
72
+ RedirectResponse.new("https://#{host}#{path}#{query.to_s.empty? ? "" : "?#{query}"}").finish
73
+ end
74
+ end
75
+ end
76
+ end