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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RubstApi
|
|
8
|
+
class ValidationError < StandardError
|
|
9
|
+
attr_reader :errors
|
|
10
|
+
def initialize(errors)
|
|
11
|
+
@errors = errors
|
|
12
|
+
super(errors.map { |e| e[:msg] }.join(", "))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Model
|
|
17
|
+
Field = Data.define(:name, :type, :default, :required, :options)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def inherited(child)
|
|
21
|
+
child.instance_variable_set(:@fields, fields.dup)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fields = @fields ||= {}
|
|
25
|
+
|
|
26
|
+
def field(name, type = String, default: UNDEFINED, required: nil, **options)
|
|
27
|
+
required = default.equal?(UNDEFINED) if required.nil?
|
|
28
|
+
fields[name.to_sym] = Field.new(name.to_sym, type, default, required, options.freeze)
|
|
29
|
+
attr_reader name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate(value, location: [:body])
|
|
33
|
+
return value if value.is_a?(self)
|
|
34
|
+
unless value.is_a?(Hash)
|
|
35
|
+
raise ValidationError, [{ type: "model_type", loc: location, msg: "Input should be an object", input: value }]
|
|
36
|
+
end
|
|
37
|
+
new(**value.transform_keys(&:to_sym))
|
|
38
|
+
rescue ValidationError => e
|
|
39
|
+
raise ValidationError, e.errors.map { |error| error.merge(loc: location + Array(error[:loc])) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def json_schema(ref_template: "#/components/schemas/%s")
|
|
43
|
+
required = fields.values.select(&:required).map { |field| field.name.to_s }
|
|
44
|
+
{
|
|
45
|
+
type: "object",
|
|
46
|
+
title: name&.split("::")&.last,
|
|
47
|
+
properties: fields.to_h { |name, field| [name, Schema.for(field.type, field.options)] }
|
|
48
|
+
}.tap { |schema| schema[:required] = required unless required.empty? }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(**attributes)
|
|
53
|
+
errors = []
|
|
54
|
+
self.class.fields.each_value do |field|
|
|
55
|
+
value = attributes.key?(field.name) ? attributes[field.name] : field.default
|
|
56
|
+
if value.equal?(UNDEFINED)
|
|
57
|
+
errors << { type: "missing", loc: [field.name], msg: "Field required", input: attributes }
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
begin
|
|
61
|
+
instance_variable_set(:"@#{field.name}", Validator.coerce(value, field.type, field.options))
|
|
62
|
+
rescue ValidationError => e
|
|
63
|
+
errors.concat(e.errors.map { |error| error.merge(loc: [field.name] + Array(error[:loc])) })
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
raise ValidationError, errors unless errors.empty?
|
|
67
|
+
freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def model_dump(exclude_none: false, by_alias: false)
|
|
71
|
+
self.class.fields.each_with_object({}) do |(name, field), output|
|
|
72
|
+
value = public_send(name)
|
|
73
|
+
next if exclude_none && value.nil?
|
|
74
|
+
key = by_alias && field.options[:alias] ? field.options[:alias] : name
|
|
75
|
+
output[key] = Serializer.dump(value)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
alias to_h model_dump
|
|
79
|
+
|
|
80
|
+
def to_json(*) = JSON.generate(model_dump)
|
|
81
|
+
def ==(other) = other.instance_of?(self.class) && other.model_dump == model_dump
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
module Validator
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
def coerce(value, type, constraints = {})
|
|
88
|
+
return nil if value.nil? && nullable?(type, constraints)
|
|
89
|
+
result =
|
|
90
|
+
if type.is_a?(Array)
|
|
91
|
+
validate_union(value, type, constraints)
|
|
92
|
+
elsif type.is_a?(Hash) && type.key?(:array)
|
|
93
|
+
Array(value).map { |item| coerce(item, type[:array], {}) }
|
|
94
|
+
elsif type.is_a?(Class) && type <= Model
|
|
95
|
+
type.validate(value, location: [])
|
|
96
|
+
elsif type == String then String(value)
|
|
97
|
+
elsif type == Integer then Integer(value, exception: true)
|
|
98
|
+
elsif type == Float then Float(value)
|
|
99
|
+
elsif type == Numeric then Float(value)
|
|
100
|
+
elsif type == TrueClass || type == FalseClass || type == :boolean then boolean(value)
|
|
101
|
+
elsif type == Symbol then value.to_sym
|
|
102
|
+
elsif type == Date then Date.parse(value.to_s)
|
|
103
|
+
elsif type == Time || type == DateTime then Time.parse(value.to_s)
|
|
104
|
+
elsif type == URI then URI.parse(value.to_s)
|
|
105
|
+
elsif type == Array then Array(value)
|
|
106
|
+
elsif type == Hash then Hash(value)
|
|
107
|
+
elsif type.respond_to?(:call) && !type.is_a?(Class) then type.call(value)
|
|
108
|
+
elsif value.is_a?(type) then value
|
|
109
|
+
else raise TypeError, "expected #{type}"
|
|
110
|
+
end
|
|
111
|
+
constrain(result, constraints)
|
|
112
|
+
rescue ValidationError
|
|
113
|
+
raise
|
|
114
|
+
rescue StandardError
|
|
115
|
+
raise ValidationError, [{ type: type_name(type), loc: [], msg: "Input should be a valid #{type_name(type)}", input: value }]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def nullable?(type, constraints) = constraints[:nullable] || (type.is_a?(Array) && type.include?(NilClass))
|
|
119
|
+
|
|
120
|
+
def validate_union(value, types, constraints)
|
|
121
|
+
return nil if value.nil? && types.include?(NilClass)
|
|
122
|
+
failures = []
|
|
123
|
+
types.reject { |type| type == NilClass }.each do |type|
|
|
124
|
+
return coerce(value, type, constraints)
|
|
125
|
+
rescue ValidationError => e
|
|
126
|
+
failures.concat(e.errors)
|
|
127
|
+
end
|
|
128
|
+
raise ValidationError, failures
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def boolean(value)
|
|
132
|
+
return value if value == true || value == false
|
|
133
|
+
return true if %w[1 true on yes].include?(value.to_s.downcase)
|
|
134
|
+
return false if %w[0 false off no].include?(value.to_s.downcase)
|
|
135
|
+
raise TypeError
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def constrain(value, options)
|
|
139
|
+
length = value.respond_to?(:length) ? value.length : nil
|
|
140
|
+
fail_constraint(value, "string_too_short", "Value is too short") if options[:min_length] && length && length < options[:min_length]
|
|
141
|
+
fail_constraint(value, "string_too_long", "Value is too long") if options[:max_length] && length && length > options[:max_length]
|
|
142
|
+
fail_constraint(value, "greater_than", "Value must be greater than #{options[:gt]}") if options[:gt] && !(value > options[:gt])
|
|
143
|
+
fail_constraint(value, "greater_than_equal", "Value must be greater than or equal to #{options[:ge]}") if options[:ge] && !(value >= options[:ge])
|
|
144
|
+
fail_constraint(value, "less_than", "Value must be less than #{options[:lt]}") if options[:lt] && !(value < options[:lt])
|
|
145
|
+
fail_constraint(value, "less_than_equal", "Value must be less than or equal to #{options[:le]}") if options[:le] && !(value <= options[:le])
|
|
146
|
+
pattern = options[:pattern] || options[:regex]
|
|
147
|
+
fail_constraint(value, "string_pattern_mismatch", "Value does not match pattern") if pattern && !(Regexp.new(pattern.to_s) =~ value.to_s)
|
|
148
|
+
fail_constraint(value, "enum", "Value is not an allowed option") if options[:enum] && !options[:enum].include?(value)
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def fail_constraint(value, type, message)
|
|
153
|
+
raise ValidationError, [{ type:, loc: [], msg: message, input: value }]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def type_name(type)
|
|
157
|
+
{ Integer => "integer", Float => "number", String => "string", TrueClass => "boolean",
|
|
158
|
+
FalseClass => "boolean", Hash => "object", Array => "array" }[type] || type.to_s.downcase
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
module Serializer
|
|
163
|
+
module_function
|
|
164
|
+
def dump(value)
|
|
165
|
+
case value
|
|
166
|
+
when Model then value.model_dump.transform_values { |item| dump(item) }
|
|
167
|
+
when Hash then value.to_h { |key, item| [key, dump(item)] }
|
|
168
|
+
when Array then value.map { |item| dump(item) }
|
|
169
|
+
when Time, Date, DateTime then value.iso8601
|
|
170
|
+
when Symbol then value.to_s
|
|
171
|
+
else
|
|
172
|
+
value.respond_to?(:to_h) && !value.is_a?(Struct) ? value.to_h : value
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
module Schema
|
|
178
|
+
module_function
|
|
179
|
+
def for(type, options = {})
|
|
180
|
+
schema =
|
|
181
|
+
if type.is_a?(Array)
|
|
182
|
+
non_nil = type.reject { |item| item == NilClass }
|
|
183
|
+
non_nil.length == 1 ? self.for(non_nil.first) : { anyOf: non_nil.map { |item| self.for(item) } }
|
|
184
|
+
elsif type.is_a?(Hash) && type.key?(:array) then { type: "array", items: self.for(type[:array]) }
|
|
185
|
+
elsif type.is_a?(Class) && type <= Model then { "$ref": "#/components/schemas/#{type.name.split("::").last}" }
|
|
186
|
+
else
|
|
187
|
+
{ String => { type: "string" }, Integer => { type: "integer" }, Float => { type: "number" },
|
|
188
|
+
Numeric => { type: "number" }, TrueClass => { type: "boolean" }, FalseClass => { type: "boolean" },
|
|
189
|
+
Array => { type: "array", items: {} }, Hash => { type: "object" },
|
|
190
|
+
Date => { type: "string", format: "date" }, Time => { type: "string", format: "date-time" } }[type] || {}
|
|
191
|
+
end
|
|
192
|
+
schema.merge(options.slice(:title, :description, :minimum, :maximum, :min_length, :max_length, :pattern, :enum).compact)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
module OpenAPI
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def generate(app)
|
|
8
|
+
schemas = {}
|
|
9
|
+
security_schemes = {}
|
|
10
|
+
paths = {}
|
|
11
|
+
app.routes.select(&:include_in_schema).each do |route|
|
|
12
|
+
operation = {
|
|
13
|
+
tags: route.tags.empty? ? nil : route.tags,
|
|
14
|
+
summary: route.summary || humanize(route.name || route.unique_id),
|
|
15
|
+
description: route.description,
|
|
16
|
+
operationId: route.unique_id,
|
|
17
|
+
deprecated: route.deprecated ? true : nil,
|
|
18
|
+
parameters: parameters_for(route, schemas),
|
|
19
|
+
responses: responses_for(route, schemas)
|
|
20
|
+
}.compact
|
|
21
|
+
security = security_for(route, security_schemes)
|
|
22
|
+
operation[:security] = security unless security.empty?
|
|
23
|
+
body = request_body_for(route, schemas)
|
|
24
|
+
operation[:requestBody] = body if body
|
|
25
|
+
route.methods.each do |method|
|
|
26
|
+
(paths[route.path] ||= {})[method.downcase.to_sym] = operation
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
{
|
|
30
|
+
openapi: app.openapi_version,
|
|
31
|
+
info: { title: app.title, version: app.version, description: app.description }.compact,
|
|
32
|
+
servers: app.servers.empty? ? nil : app.servers,
|
|
33
|
+
paths:,
|
|
34
|
+
components: {
|
|
35
|
+
schemas: schemas.empty? ? nil : schemas,
|
|
36
|
+
securitySchemes: security_schemes.empty? ? nil : security_schemes
|
|
37
|
+
}.compact,
|
|
38
|
+
tags: app.openapi_tags.empty? ? nil : app.openapi_tags
|
|
39
|
+
}.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def security_for(route, schemes)
|
|
43
|
+
(route.dependencies + route.params.values.grep(Dependency)).filter_map do |dependency|
|
|
44
|
+
callable = dependency.callable
|
|
45
|
+
next unless callable.respond_to?(:openapi_scheme)
|
|
46
|
+
name = callable.scheme_name
|
|
47
|
+
schemes[name] ||= callable.openapi_scheme
|
|
48
|
+
{ name => dependency.is_a?(SecurityDependency) ? dependency.scopes : [] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parameters_for(route, schemas)
|
|
53
|
+
route.params.filter_map do |name, param|
|
|
54
|
+
next if param.is_a?(Dependency)
|
|
55
|
+
next if param.location == :body || param.location == :form || param.location == :file || !param.include_in_schema
|
|
56
|
+
collect_schema(param.type, schemas)
|
|
57
|
+
{
|
|
58
|
+
name: param.name_for(name), in: param.location.to_s, required: param.required?,
|
|
59
|
+
description: param.description, deprecated: param.deprecated,
|
|
60
|
+
schema: Schema.for(param.type, param.constraints)
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request_body_for(route, schemas)
|
|
66
|
+
body_params = route.params.select { |_, param| param.is_a?(Param) && %i[body form file].include?(param.location) }
|
|
67
|
+
return if body_params.empty?
|
|
68
|
+
body_params.each_value { |param| collect_schema(param.type, schemas) }
|
|
69
|
+
media_type = body_params.values.any? { |param| param.location == :file } ? "multipart/form-data" :
|
|
70
|
+
body_params.values.any? { |param| param.location == :form } ? "application/x-www-form-urlencoded" : "application/json"
|
|
71
|
+
schema = if body_params.length == 1
|
|
72
|
+
Schema.for(body_params.values.first.type)
|
|
73
|
+
else
|
|
74
|
+
{ type: "object", properties: body_params.to_h { |name, param| [name, Schema.for(param.type)] },
|
|
75
|
+
required: body_params.select { |_, param| param.required? }.keys }
|
|
76
|
+
end
|
|
77
|
+
{ required: body_params.values.any?(&:required?), content: { media_type => { schema: } } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def responses_for(route, schemas)
|
|
81
|
+
schema = route.response_model ? Schema.for(route.response_model) : {}
|
|
82
|
+
collect_schema(route.response_model, schemas) if route.response_model
|
|
83
|
+
output = { route.status_code.to_s => { description: status_description(route.status_code),
|
|
84
|
+
content: { "application/json" => { schema: } } } }
|
|
85
|
+
output["422"] = { description: "Validation Error", content: { "application/json" => {
|
|
86
|
+
schema: { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
87
|
+
} } } unless route.params.empty?
|
|
88
|
+
route.responses.each { |code, definition| output[code.to_s] = definition }
|
|
89
|
+
output
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collect_schema(type, schemas)
|
|
93
|
+
if type.is_a?(Class) && type <= Model
|
|
94
|
+
schemas[type.name.split("::").last] ||= type.json_schema
|
|
95
|
+
type.fields.each_value { |field| collect_schema(field.type, schemas) }
|
|
96
|
+
elsif type.is_a?(Array)
|
|
97
|
+
type.each { |item| collect_schema(item, schemas) }
|
|
98
|
+
elsif type.is_a?(Hash) && type[:array]
|
|
99
|
+
collect_schema(type[:array], schemas)
|
|
100
|
+
end
|
|
101
|
+
schemas["ValidationError"] ||= {
|
|
102
|
+
type: "object", properties: {
|
|
103
|
+
loc: { type: "array", items: { anyOf: [{ type: "string" }, { type: "integer" }] } },
|
|
104
|
+
msg: { type: "string" }, type: { type: "string" }
|
|
105
|
+
}, required: %w[loc msg type]
|
|
106
|
+
}
|
|
107
|
+
schemas["HTTPValidationError"] ||= {
|
|
108
|
+
type: "object", properties: { detail: { type: "array", items: { "$ref": "#/components/schemas/ValidationError" } } }
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def status_description(code)
|
|
113
|
+
{ 200 => "Successful Response", 201 => "Successful Response", 202 => "Successful Response",
|
|
114
|
+
204 => "Successful Response", 301 => "Redirect Response", 307 => "Redirect Response" }[code] || "Response"
|
|
115
|
+
end
|
|
116
|
+
def humanize(value) = value.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
UNDEFINED = Object.new.freeze
|
|
5
|
+
|
|
6
|
+
class Param
|
|
7
|
+
attr_reader :location, :type, :default, :alias_name, :title, :description,
|
|
8
|
+
:examples, :deprecated, :include_in_schema, :constraints
|
|
9
|
+
|
|
10
|
+
def initialize(location, type: String, default: UNDEFINED, required: nil,
|
|
11
|
+
alias_name: nil, alias: nil, title: nil, description: nil,
|
|
12
|
+
examples: nil, deprecated: false, include_in_schema: true, **constraints)
|
|
13
|
+
@location, @type, @default = location, type, default
|
|
14
|
+
@required = required.nil? ? default.equal?(UNDEFINED) : required
|
|
15
|
+
@alias_name = alias_name || binding.local_variable_get(:alias)
|
|
16
|
+
@title, @description, @examples = title, description, examples
|
|
17
|
+
@deprecated, @include_in_schema = deprecated, include_in_schema
|
|
18
|
+
@constraints = constraints
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def required? = @required
|
|
22
|
+
def name_for(name) = (alias_name || name).to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.Query(...) = RubstApi.Query(...)
|
|
26
|
+
def self.Path(...) = RubstApi.Path(...)
|
|
27
|
+
def self.Header(...) = RubstApi.Header(...)
|
|
28
|
+
def self.Cookie(...) = RubstApi.Cookie(...)
|
|
29
|
+
def self.Body(...) = RubstApi.Body(...)
|
|
30
|
+
def self.Form(...) = RubstApi.Form(...)
|
|
31
|
+
def self.File(...) = RubstApi.File(...)
|
|
32
|
+
|
|
33
|
+
class UploadFile
|
|
34
|
+
attr_reader :filename, :content_type, :headers, :io
|
|
35
|
+
def initialize(filename:, io:, content_type: "application/octet-stream", headers: {})
|
|
36
|
+
@filename, @io, @content_type, @headers = filename, io, content_type, headers
|
|
37
|
+
end
|
|
38
|
+
def read(...) = io.read(...)
|
|
39
|
+
def rewind = io.rewind
|
|
40
|
+
def close = io.close
|
|
41
|
+
def size = io.size
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubstApi
|
|
6
|
+
class Response
|
|
7
|
+
attr_accessor :status_code, :body
|
|
8
|
+
attr_reader :headers, :media_type
|
|
9
|
+
def initialize(content = nil, status_code: 200, headers: {}, media_type: "text/plain; charset=utf-8")
|
|
10
|
+
@body, @status_code, @headers, @media_type = render(content), status_code, Headers.new(headers), media_type
|
|
11
|
+
@headers["content-type"] ||= media_type
|
|
12
|
+
end
|
|
13
|
+
def render(content) = content.nil? ? "" : content.to_s
|
|
14
|
+
def set_cookie(key, value, max_age: nil, expires: nil, path: "/", domain: nil, secure: false, httponly: false, samesite: "lax")
|
|
15
|
+
parts = ["#{key}=#{value}", ("Max-Age=#{max_age}" if max_age), ("Expires=#{expires.httpdate}" if expires),
|
|
16
|
+
("Path=#{path}" if path), ("Domain=#{domain}" if domain), ("Secure" if secure),
|
|
17
|
+
("HttpOnly" if httponly), ("SameSite=#{samesite.to_s.capitalize}" if samesite)].compact
|
|
18
|
+
headers["set-cookie"] = parts.join("; ")
|
|
19
|
+
end
|
|
20
|
+
def delete_cookie(key, **options) = set_cookie(key, "", max_age: 0, expires: Time.at(0), **options)
|
|
21
|
+
def finish
|
|
22
|
+
payload = Array(body)
|
|
23
|
+
headers["content-length"] ||= payload.sum(&:bytesize).to_s
|
|
24
|
+
[status_code, headers.to_h, payload]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class JSONResponse < Response
|
|
29
|
+
def initialize(content = nil, **options)
|
|
30
|
+
super(content, media_type: "application/json", **options)
|
|
31
|
+
end
|
|
32
|
+
def render(content) = JSON.generate(Serializer.dump(content))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class HTMLResponse < Response
|
|
36
|
+
def initialize(content = nil, **options) = super(content, media_type: "text/html; charset=utf-8", **options)
|
|
37
|
+
end
|
|
38
|
+
class PlainTextResponse < Response; end
|
|
39
|
+
class RedirectResponse < Response
|
|
40
|
+
def initialize(url, status_code: 307, headers: {}, **)
|
|
41
|
+
super("", status_code:, headers: headers.merge("location" => url))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
class StreamingResponse < Response
|
|
45
|
+
def initialize(content, **options)
|
|
46
|
+
super("", **options)
|
|
47
|
+
@body = content.respond_to?(:each) ? content : [content]
|
|
48
|
+
end
|
|
49
|
+
def finish = [status_code, headers.to_h, body]
|
|
50
|
+
end
|
|
51
|
+
class FileResponse < StreamingResponse
|
|
52
|
+
def initialize(path, filename: nil, media_type: "application/octet-stream", **options)
|
|
53
|
+
headers = options.delete(:headers) || {}
|
|
54
|
+
headers["content-disposition"] = %(attachment; filename="#{filename}") if filename
|
|
55
|
+
super(File.open(path, "rb"), media_type:, headers:, **options)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
ORJSONResponse = JSONResponse
|
|
59
|
+
UJSONResponse = JSONResponse
|
|
60
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
class Route
|
|
5
|
+
attr_reader :path, :methods, :endpoint, :params, :response_model, :status_code,
|
|
6
|
+
:tags, :summary, :description, :operation_id, :deprecated,
|
|
7
|
+
:include_in_schema, :responses, :dependencies, :name
|
|
8
|
+
|
|
9
|
+
def initialize(path, endpoint:, methods:, params: {}, response_model: nil, status_code: 200,
|
|
10
|
+
tags: [], summary: nil, description: nil, operation_id: nil, deprecated: false,
|
|
11
|
+
include_in_schema: true, responses: {}, dependencies: [], name: nil, **)
|
|
12
|
+
@path, @endpoint, @methods, @params = normalize_path(path), endpoint, Array(methods).map { |m| m.to_s.upcase }, params
|
|
13
|
+
@response_model, @status_code, @tags = response_model, status_code, tags
|
|
14
|
+
@summary, @description, @operation_id = summary, description, operation_id
|
|
15
|
+
@deprecated, @include_in_schema, @responses = deprecated, include_in_schema, responses
|
|
16
|
+
@dependencies, @name = dependencies, name
|
|
17
|
+
@regexp, @path_names = compile(@path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def match(request_path)
|
|
21
|
+
match = @regexp.match(request_path)
|
|
22
|
+
match&.named_captures || nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def allowed?(method) = methods.include?(method) || (method == "HEAD" && methods.include?("GET"))
|
|
26
|
+
|
|
27
|
+
def unique_id
|
|
28
|
+
operation_id || "#{name || endpoint_name}_#{path.gsub(/\W+/, "_")}_#{methods.first.downcase}".gsub(/\A_|_\z/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def normalize_path(path) = path.start_with?("/") ? path : "/#{path}"
|
|
34
|
+
def endpoint_name = endpoint.respond_to?(:name) && endpoint.name ? endpoint.name : "operation"
|
|
35
|
+
def compile(path)
|
|
36
|
+
names = path.scan(/\{([a-zA-Z_]\w*)\}/).flatten
|
|
37
|
+
source = Regexp.escape(path).gsub(/\\\{([a-zA-Z_]\w*)\\\}/, '(?<\1>[^/]+)')
|
|
38
|
+
[Regexp.new("\\A#{source}/?\\z"), names]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class APIRouter
|
|
43
|
+
HTTP_METHODS = %i[get post put patch delete options head trace].freeze
|
|
44
|
+
attr_reader :routes, :prefix, :tags, :dependencies
|
|
45
|
+
|
|
46
|
+
def initialize(prefix: "", tags: [], dependencies: [], **defaults)
|
|
47
|
+
@prefix, @tags, @dependencies, @defaults = prefix, tags, dependencies, defaults
|
|
48
|
+
@routes = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
HTTP_METHODS.each do |method|
|
|
52
|
+
define_method(method) do |path, endpoint = nil, **options, &block|
|
|
53
|
+
add_api_route(path, endpoint || block, methods: [method], **options)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def api_route(path, methods: ["GET"], **options, &block)
|
|
58
|
+
add_api_route(path, block, methods:, **options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_api_route(path, endpoint = nil, methods: ["GET"], **options, &block)
|
|
62
|
+
callable = endpoint || block
|
|
63
|
+
raise ArgumentError, "an endpoint block or callable is required" unless callable.respond_to?(:call)
|
|
64
|
+
route = Route.new("#{prefix}#{path}", endpoint: callable, methods:,
|
|
65
|
+
tags: tags + Array(options.delete(:tags)),
|
|
66
|
+
dependencies: dependencies + Array(options.delete(:dependencies)),
|
|
67
|
+
**@defaults, **options)
|
|
68
|
+
routes << route
|
|
69
|
+
route
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def include_router(router, prefix: "", tags: [], dependencies: [])
|
|
73
|
+
router.routes.each do |route|
|
|
74
|
+
routes << Route.new("#{prefix}#{route.path}", endpoint: route.endpoint, methods: route.methods,
|
|
75
|
+
params: route.params, response_model: route.response_model,
|
|
76
|
+
status_code: route.status_code, tags: tags + route.tags,
|
|
77
|
+
summary: route.summary, description: route.description,
|
|
78
|
+
operation_id: route.operation_id, deprecated: route.deprecated,
|
|
79
|
+
include_in_schema: route.include_in_schema, responses: route.responses,
|
|
80
|
+
dependencies: dependencies + route.dependencies, name: route.name)
|
|
81
|
+
end
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def websocket(path, endpoint = nil, **options, &block)
|
|
86
|
+
add_api_route(path, endpoint || block, methods: ["WEBSOCKET"], include_in_schema: false, **options)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module RubstApi
|
|
6
|
+
module Security
|
|
7
|
+
HTTPAuthorizationCredentials = Data.define(:scheme, :credentials)
|
|
8
|
+
HTTPBasicCredentials = Data.define(:username, :password)
|
|
9
|
+
SecurityScopes = Data.define(:scopes) do
|
|
10
|
+
def scope_str = scopes.join(" ")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Base
|
|
14
|
+
attr_reader :scheme_name, :description, :auto_error
|
|
15
|
+
def initialize(scheme_name: nil, description: nil, auto_error: true)
|
|
16
|
+
@scheme_name = scheme_name || self.class.name.split("::").last
|
|
17
|
+
@description, @auto_error = description, auto_error
|
|
18
|
+
end
|
|
19
|
+
def fail_auth(detail = "Not authenticated")
|
|
20
|
+
raise HTTPException.new(status_code: 401, detail:, headers: { "WWW-Authenticate" => scheme_name })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class HTTPBearer < Base
|
|
25
|
+
def call(request:)
|
|
26
|
+
scheme, credentials = request.headers["authorization"]&.split(" ", 2)
|
|
27
|
+
return fail_auth unless scheme&.casecmp?("Bearer") && credentials
|
|
28
|
+
HTTPAuthorizationCredentials.new(scheme, credentials)
|
|
29
|
+
rescue HTTPException
|
|
30
|
+
raise if auto_error
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
def openapi_scheme = { type: "http", scheme: "bearer", description: description }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class HTTPBasic < Base
|
|
37
|
+
def call(request:)
|
|
38
|
+
scheme, credentials = request.headers["authorization"]&.split(" ", 2)
|
|
39
|
+
return fail_auth unless scheme&.casecmp?("Basic") && credentials
|
|
40
|
+
username, password = Base64.strict_decode64(credentials).split(":", 2)
|
|
41
|
+
HTTPBasicCredentials.new(username, password)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
raise if auto_error
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
def openapi_scheme = { type: "http", scheme: "basic", description: description }.compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class APIKeyHeader < Base
|
|
50
|
+
def initialize(name:, **options) = (@name = name; super(**options))
|
|
51
|
+
def call(request:)
|
|
52
|
+
request.headers[@name] || (auto_error ? fail_auth("Not authenticated") : nil)
|
|
53
|
+
end
|
|
54
|
+
def openapi_scheme = { type: "apiKey", in: "header", name: @name, description: description }.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class APIKeyQuery < Base
|
|
58
|
+
def initialize(name:, **options) = (@name = name; super(**options))
|
|
59
|
+
def call(request:)
|
|
60
|
+
request.query_params[@name] || (auto_error ? fail_auth("Not authenticated") : nil)
|
|
61
|
+
end
|
|
62
|
+
def openapi_scheme = { type: "apiKey", in: "query", name: @name, description: description }.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class APIKeyCookie < Base
|
|
66
|
+
def initialize(name:, **options) = (@name = name; super(**options))
|
|
67
|
+
def call(request:)
|
|
68
|
+
request.cookies[@name] || (auto_error ? fail_auth("Not authenticated") : nil)
|
|
69
|
+
end
|
|
70
|
+
def openapi_scheme = { type: "apiKey", in: "cookie", name: @name, description: description }.compact
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class OAuth2PasswordBearer < HTTPBearer
|
|
74
|
+
attr_reader :token_url, :scopes
|
|
75
|
+
def initialize(token_url:, scopes: {}, **options)
|
|
76
|
+
@token_url, @scopes = token_url, scopes
|
|
77
|
+
super(**options)
|
|
78
|
+
end
|
|
79
|
+
def call(request:) = super.credentials
|
|
80
|
+
def openapi_scheme
|
|
81
|
+
{ type: "oauth2", flows: { password: { tokenUrl: token_url, scopes: scopes } }, description: description }.compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
ServerSentEvent = Data.define(:data, :event, :id, :retry, :comment) do
|
|
5
|
+
def encode
|
|
6
|
+
lines = []
|
|
7
|
+
lines << ": #{comment}" if comment
|
|
8
|
+
lines << "id: #{id}" if id
|
|
9
|
+
lines << "event: #{event}" if event
|
|
10
|
+
lines << "retry: #{self.retry}" if self.retry
|
|
11
|
+
payload = data.is_a?(String) ? data : JSON.generate(Serializer.dump(data))
|
|
12
|
+
payload.each_line { |line| lines << "data: #{line.chomp}" }
|
|
13
|
+
"#{lines.join("\n")}\n\n"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class EventSourceResponse < StreamingResponse
|
|
18
|
+
def initialize(content, ping: 15, **options)
|
|
19
|
+
stream = Enumerator.new do |output|
|
|
20
|
+
content.each do |event|
|
|
21
|
+
event = ServerSentEvent.new(event, nil, nil, nil, nil) unless event.is_a?(ServerSentEvent)
|
|
22
|
+
output << event.encode
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
super(stream, media_type: "text/event-stream", headers: { "cache-control" => "no-cache" }, **options)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
class StaticFiles
|
|
5
|
+
MIME_TYPES = {
|
|
6
|
+
".html" => "text/html; charset=utf-8", ".css" => "text/css; charset=utf-8",
|
|
7
|
+
".js" => "application/javascript", ".json" => "application/json",
|
|
8
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
9
|
+
".svg" => "image/svg+xml", ".txt" => "text/plain; charset=utf-8"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(directory:, html: false, check_dir: true)
|
|
13
|
+
@directory, @html = File.expand_path(directory), html
|
|
14
|
+
raise ArgumentError, "Directory does not exist: #{directory}" if check_dir && !Dir.exist?(@directory)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
relative = URI.decode_www_form_component(env.fetch("PATH_INFO", "/")).sub(%r{\A/+}, "")
|
|
19
|
+
path = File.expand_path(relative, @directory)
|
|
20
|
+
return not_found unless path == @directory || path.start_with?("#{@directory}#{File::SEPARATOR}")
|
|
21
|
+
path = File.join(path, "index.html") if @html && File.directory?(path)
|
|
22
|
+
return not_found unless File.file?(path)
|
|
23
|
+
FileResponse.new(path, media_type: MIME_TYPES.fetch(File.extname(path).downcase, "application/octet-stream")).finish
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def not_found = PlainTextResponse.new("Not Found", status_code: 404).finish
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubstApi
|
|
4
|
+
module Status
|
|
5
|
+
HTTP_100_CONTINUE = 100
|
|
6
|
+
HTTP_200_OK = 200
|
|
7
|
+
HTTP_201_CREATED = 201
|
|
8
|
+
HTTP_202_ACCEPTED = 202
|
|
9
|
+
HTTP_204_NO_CONTENT = 204
|
|
10
|
+
HTTP_301_MOVED_PERMANENTLY = 301
|
|
11
|
+
HTTP_302_FOUND = 302
|
|
12
|
+
HTTP_304_NOT_MODIFIED = 304
|
|
13
|
+
HTTP_307_TEMPORARY_REDIRECT = 307
|
|
14
|
+
HTTP_308_PERMANENT_REDIRECT = 308
|
|
15
|
+
HTTP_400_BAD_REQUEST = 400
|
|
16
|
+
HTTP_401_UNAUTHORIZED = 401
|
|
17
|
+
HTTP_403_FORBIDDEN = 403
|
|
18
|
+
HTTP_404_NOT_FOUND = 404
|
|
19
|
+
HTTP_405_METHOD_NOT_ALLOWED = 405
|
|
20
|
+
HTTP_409_CONFLICT = 409
|
|
21
|
+
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
|
|
22
|
+
HTTP_422_UNPROCESSABLE_ENTITY = 422
|
|
23
|
+
HTTP_429_TOO_MANY_REQUESTS = 429
|
|
24
|
+
HTTP_500_INTERNAL_SERVER_ERROR = 500
|
|
25
|
+
HTTP_502_BAD_GATEWAY = 502
|
|
26
|
+
HTTP_503_SERVICE_UNAVAILABLE = 503
|
|
27
|
+
end
|
|
28
|
+
end
|