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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +17 -0
- data/LICENSES/FASTAPI-MIT.txt +21 -0
- data/README.md +618 -0
- data/SECURITY.md +29 -0
- data/THIRD_PARTY_NOTICES.md +20 -0
- data/exe/rubst_api +29 -0
- data/lib/rubst_api/app.rb +253 -0
- data/lib/rubst_api/background_tasks.rb +18 -0
- data/lib/rubst_api/dependencies.rb +57 -0
- data/lib/rubst_api/errors.rb +46 -0
- data/lib/rubst_api/http.rb +52 -0
- data/lib/rubst_api/middleware.rb +76 -0
- data/lib/rubst_api/model.rb +195 -0
- data/lib/rubst_api/openapi.rb +118 -0
- data/lib/rubst_api/params.rb +43 -0
- data/lib/rubst_api/responses.rb +60 -0
- data/lib/rubst_api/routing.rb +89 -0
- data/lib/rubst_api/security.rb +85 -0
- data/lib/rubst_api/sse.rb +28 -0
- data/lib/rubst_api/static_files.rb +30 -0
- data/lib/rubst_api/status.rb +28 -0
- data/lib/rubst_api/test_client.rb +51 -0
- data/lib/rubst_api/version.rb +5 -0
- data/lib/rubst_api/websocket.rb +21 -0
- data/lib/rubst_api.rb +40 -0
- metadata +182 -0
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
|