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.
@@ -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