fastrb 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,58 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require "date"
4
+ require "bigdecimal"
5
+
6
+ module RubyAPI
7
+ Boolean = Class.new unless defined?(Boolean)
8
+ UUID = Class.new unless defined?(UUID)
9
+ Decimal = BigDecimal unless defined?(Decimal)
10
+
11
+ class ConversionError < StandardError
12
+ def initialize(param_name, type_name)
13
+ super("#{param_name} must be #{type_name}")
14
+ end
15
+ end
16
+
17
+ module ParamConverters
18
+ CONVERTERS = {
19
+ String => ->(v) { v.to_s },
20
+ Integer => ->(v) { Integer(v) },
21
+ Float => ->(v) { Float(v) },
22
+ Boolean => ->(v) {
23
+ case v.to_s.downcase
24
+ when "true", "1", "yes" then true
25
+ when "false", "0", "no", "nil" then false
26
+ else raise ArgumentError
27
+ end
28
+ },
29
+ Date => ->(v) { Date.parse(v) },
30
+ Time => ->(v) { Time.parse(v) },
31
+ DateTime => ->(v) { DateTime.parse(v) },
32
+ Array => ->(v) {
33
+ return v if v.is_a?(Array)
34
+ JSON.parse(v)
35
+ },
36
+ Hash => ->(v) {
37
+ return v if v.is_a?(Hash)
38
+ JSON.parse(v)
39
+ },
40
+ UUID => ->(v) {
41
+ uuid = v.to_s
42
+ raise ArgumentError unless uuid.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
43
+ uuid
44
+ },
45
+ Decimal => ->(v) { BigDecimal(v.to_s) },
46
+ Symbol => ->(v) { v.to_sym }
47
+ }.freeze
48
+
49
+ def self.convert(value, type)
50
+ converter = CONVERTERS[type]
51
+ return value.to_s unless converter
52
+ converter.call(value)
53
+ rescue ArgumentError, TypeError, JSON::ParserError
54
+ type_name = type.respond_to?(:name) ? type.name : type.to_s
55
+ raise ConversionError.new(value.to_s, type_name)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ module RubyAPI
2
+ class Plugin
3
+ def self.inherited(subclass)
4
+ RubyAPI::PluginRegistry.register(subclass)
5
+ end
6
+
7
+ def self.on_load(app); end
8
+ def self.register_routes(app); end
9
+ def self.register_cli(commands); end
10
+ def self.plugin_name
11
+ name.split("::").last
12
+ end
13
+
14
+ def self.options
15
+ @options ||= {}
16
+ end
17
+
18
+ def self.option(key, value = nil)
19
+ @options ||= {}
20
+ @options[key] = value
21
+ end
22
+ end
23
+
24
+ class PluginRegistry
25
+ def self.plugins
26
+ @plugins ||= []
27
+ end
28
+
29
+ def self.register(klass)
30
+ plugins << klass unless plugins.include?(klass)
31
+ end
32
+
33
+ def self.find(name)
34
+ plugins.find { |p| p.plugin_name == name.to_s || p == name }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module RubyAPI
2
+ module Plugins
3
+ class Auth < Plugin
4
+ option :unauthorized_status, 401
5
+ option :unauthorized_body, { error: "Unauthorized" }
6
+
7
+ def self.on_load(app)
8
+ app.before do |ctx|
9
+ payload = ctx.get(:jwt_payload)
10
+ unless payload
11
+ status = options[:unauthorized_status]
12
+ body = options[:unauthorized_body]
13
+ ctx.response_status = status
14
+ ctx.response_body = body
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ module RubyAPI
2
+ module Plugins
3
+ class Cache < Plugin
4
+ option :store, {}
5
+ option :default_ttl, 3600
6
+
7
+ def self.store
8
+ options[:store]
9
+ end
10
+
11
+ def self.get(key)
12
+ entry = store[key]
13
+ return nil unless entry
14
+ return nil if Time.now > entry[:expires_at]
15
+ entry[:value]
16
+ end
17
+
18
+ def self.set(key, value, ttl: nil)
19
+ ttl ||= options[:default_ttl]
20
+ store[key] = {
21
+ value: value,
22
+ expires_at: Time.now + ttl
23
+ }
24
+ end
25
+
26
+ def self.delete(key)
27
+ store.delete(key)
28
+ end
29
+
30
+ def self.clear
31
+ store.clear
32
+ end
33
+
34
+ def self.on_load(app)
35
+ app.before do |ctx|
36
+ ctx.set(:cache, self)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ module RubyAPI
2
+ module Plugins
3
+ class CORS < Plugin
4
+ option :origins, ["*"]
5
+ option :methods, ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
6
+ option :headers, ["Content-Type", "Authorization"]
7
+ option :allow_credentials, false
8
+ option :max_age, 86400
9
+
10
+ def self.on_load(app)
11
+ allowed = options[:origins]
12
+ methods = options[:methods].join(", ")
13
+ headers = options[:headers].join(", ")
14
+ credentials = options[:allow_credentials]
15
+ max_age = options[:max_age]
16
+
17
+ app.before do |ctx|
18
+ origin = ctx.request.get_header("HTTP_ORIGIN")
19
+ next unless allowed.include?("*") || allowed.include?(origin)
20
+
21
+ ctx.response_headers["access-control-allow-origin"] = origin || "*"
22
+ ctx.response_headers["access-control-allow-methods"] = methods
23
+ ctx.response_headers["access-control-allow-headers"] = headers
24
+ ctx.response_headers["access-control-allow-credentials"] = credentials.to_s if credentials
25
+ ctx.response_headers["access-control-max-age"] = max_age.to_s
26
+
27
+ if ctx.request.request_method == "OPTIONS"
28
+ ctx.response_status = 204
29
+ ctx.response_body = ""
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ require "json"
2
+ require "base64"
3
+ require "openssl"
4
+ require "jwt"
5
+
6
+ module RubyAPI
7
+ module Plugins
8
+ class JWT < Plugin
9
+ option :secret, "default_secret"
10
+ option :algorithm, "HS256"
11
+ option :header, "Authorization"
12
+ option :prefix, "Bearer "
13
+
14
+ def self.decode(token)
15
+ secret = options[:secret]
16
+ algorithm = options[:algorithm]
17
+ payload, _header = ::JWT.decode(token, secret, true, algorithm: algorithm)
18
+ payload
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def self.encode(payload)
24
+ secret = options[:secret]
25
+ algorithm = options[:algorithm]
26
+ ::JWT.encode(payload, secret, algorithm)
27
+ end
28
+
29
+ def self.on_load(app)
30
+ app.before do |ctx|
31
+ header_value = ctx.request.get_header("HTTP_AUTHORIZATION")
32
+ next unless header_value
33
+
34
+ prefix = options[:prefix]
35
+ token = header_value.sub(/\A#{Regexp.escape(prefix)}/, "")
36
+ payload = decode(token)
37
+ ctx.set(:jwt_payload, payload) if payload
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,99 @@
1
+ module RubyAPI
2
+ class Router
3
+ attr_reader :routes, :current_prefix
4
+
5
+ def initialize
6
+ @routes = []
7
+ @trie = TrieNode.new
8
+ @current_prefix = ""
9
+ end
10
+
11
+ def add_route(method, path, **opts, &block)
12
+ full_path = @current_prefix + path
13
+ pattern = compile_pattern(full_path)
14
+ param_names = extract_param_names(full_path)
15
+
16
+ route = {
17
+ method: method,
18
+ path: full_path,
19
+ pattern: pattern,
20
+ param_names: param_names,
21
+ block: block,
22
+ params: opts[:params] || {},
23
+ body: opts[:body],
24
+ injected_deps: opts[:inject] || [],
25
+ dependency_checks: opts[:depends] || []
26
+ }
27
+
28
+ @routes << route
29
+ @trie.insert(full_path, route)
30
+ route
31
+ end
32
+
33
+ def push_prefix(prefix)
34
+ @current_prefix = @current_prefix + prefix
35
+ end
36
+
37
+ def pop_prefix(prefix)
38
+ @current_prefix = @current_prefix[0...-prefix.length]
39
+ end
40
+
41
+ def find(method, path)
42
+ route = @trie.search(path)
43
+ return nil unless route
44
+ return nil unless route[:method].to_s == method.to_s
45
+ route
46
+ end
47
+
48
+ private
49
+
50
+ def compile_pattern(path)
51
+ segments = path.split("/")
52
+ pattern_parts = segments.map do |seg|
53
+ if seg.start_with?(":")
54
+ "([^/]+)"
55
+ elsif seg == "*"
56
+ "(.+)"
57
+ else
58
+ Regexp.escape(seg)
59
+ end
60
+ end
61
+ /\A#{pattern_parts.join("/")}\z/
62
+ end
63
+
64
+ def extract_param_names(path)
65
+ path.split("/").select { |s| s.start_with?(":") }.map { |s| s[1..].to_sym }
66
+ end
67
+ end
68
+
69
+ class TrieNode
70
+ attr_accessor :children, :route
71
+
72
+ def initialize
73
+ @children = {}
74
+ @route = nil
75
+ end
76
+
77
+ def insert(path, route)
78
+ segments = path.split("/").reject(&:empty?)
79
+ node = self
80
+ segments.each do |seg|
81
+ child_key = seg.start_with?(":") ? :__param__ : seg
82
+ node.children[child_key] ||= TrieNode.new
83
+ node = node.children[child_key]
84
+ end
85
+ node.route = route
86
+ end
87
+
88
+ def search(path)
89
+ segments = path.split("/").reject(&:empty?)
90
+ node = self
91
+ segments.each do |seg|
92
+ child = node.children[seg] || node.children[:__param__]
93
+ return nil unless child
94
+ node = child
95
+ end
96
+ node.route
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,46 @@
1
+ module RubyAPI
2
+ class Schema
3
+ class ValidationError < StandardError
4
+ attr_reader :errors
5
+
6
+ def initialize(errors)
7
+ @errors = errors
8
+ super("Validation failed: #{errors.map { |k, v| "#{k}: #{v}" }.join(', ')}")
9
+ end
10
+ end
11
+
12
+ def self.field(name, type: String)
13
+ @fields ||= {}
14
+ @fields[name] = type
15
+ end
16
+
17
+ def self.fields
18
+ @fields || {}
19
+ end
20
+
21
+ def self.inherited(subclass)
22
+ subclass.instance_variable_set(:@fields, (@fields || {}).dup)
23
+ end
24
+
25
+ def self.validate(data)
26
+ errors = {}
27
+ result = {}
28
+
29
+ fields.each do |name, type|
30
+ value = data.is_a?(Hash) ? (data[name.to_s] || data[name]) : nil
31
+ if value.nil?
32
+ errors[name] = "is required"
33
+ next
34
+ end
35
+ begin
36
+ result[name] = ParamConverters.convert(value, type)
37
+ rescue ConversionError => e
38
+ errors[name] = e.message
39
+ end
40
+ end
41
+
42
+ raise ValidationError.new(errors) if errors.any?
43
+ result
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ module RubyAPI
2
+ module Serializer
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def field(name, type: String)
9
+ @fields ||= {}
10
+ @fields[name] = type
11
+ end
12
+
13
+ def fields
14
+ @fields || {}
15
+ end
16
+ end
17
+
18
+ def serialize(object)
19
+ self.class.fields.each_with_object({}) do |(name, type), hash|
20
+ value = object.send(name)
21
+ hash[name] = value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,62 @@
1
+ require "rack"
2
+ require "json"
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module RubyAPI
7
+ class Session
8
+ SESSION_COOKIE = "_rubyapi_session".freeze
9
+
10
+ def initialize(request, secret_key: nil)
11
+ @request = request
12
+ @secret_key = secret_key || "rubyapi_default_secret"
13
+ @data = load_session
14
+ end
15
+
16
+ def [](key)
17
+ @data[key.to_s]
18
+ end
19
+
20
+ def []=(key, value)
21
+ @data[key.to_s] = value
22
+ end
23
+
24
+ def to_hash
25
+ @data.dup
26
+ end
27
+
28
+ def serialize
29
+ json = JSON.generate(@data)
30
+ encode(json)
31
+ end
32
+
33
+ private
34
+
35
+ def load_session
36
+ cookie_value = @request.cookies[SESSION_COOKIE]
37
+ return {} unless cookie_value
38
+ json = decode(cookie_value)
39
+ JSON.parse(json)
40
+ rescue StandardError
41
+ {}
42
+ end
43
+
44
+ def encode(data)
45
+ encoded = Base64.strict_encode64(data)
46
+ signature = sign(encoded)
47
+ "#{encoded}.#{signature}"
48
+ end
49
+
50
+ def decode(data)
51
+ encoded, signature = data.to_s.split(".", 2)
52
+ return nil unless encoded && signature
53
+ expected = sign(encoded)
54
+ return nil unless Rack::Utils.secure_compare(signature, expected)
55
+ Base64.strict_decode64(encoded)
56
+ end
57
+
58
+ def sign(data)
59
+ OpenSSL::HMAC.hexdigest("SHA256", @secret_key, data)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ module RubyAPI
2
+ class SSE
3
+ attr_reader :io
4
+
5
+ def initialize(io)
6
+ @io = io
7
+ @closed = false
8
+ end
9
+
10
+ def send(event:, data:, id: nil)
11
+ return if @closed
12
+ @io.write("event: #{event}\n") if event
13
+ @io.write("id: #{id}\n") if id
14
+ data.to_s.each_line do |line|
15
+ @io.write("data: #{line}")
16
+ end
17
+ @io.write("\n")
18
+ @io.flush
19
+ end
20
+
21
+ def close
22
+ @closed = true
23
+ @io.close if @io.respond_to?(:close) && !@io.closed?
24
+ end
25
+
26
+ def closed?
27
+ @closed
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ require "securerandom"
2
+
3
+ module RubyAPI
4
+ class StreamingBody
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @buffer = Queue.new
9
+ @closed = false
10
+ end
11
+
12
+ def write(data)
13
+ @buffer << data.to_s
14
+ end
15
+
16
+ def close
17
+ @closed = true
18
+ @buffer << nil
19
+ end
20
+
21
+ def each
22
+ loop do
23
+ data = @buffer.pop
24
+ break if data.nil?
25
+ yield data
26
+ end
27
+ end
28
+
29
+ def closed?
30
+ @closed
31
+ end
32
+ end
33
+
34
+ class SSEStream
35
+ attr_reader :body
36
+
37
+ def initialize
38
+ @body = StreamingBody.new
39
+ @sse = SSE.new(@body)
40
+ @closed = false
41
+ end
42
+
43
+ def send_event(event:, data:, id: nil)
44
+ return if @closed
45
+ @sse.send(event: event, data: data, id: id)
46
+ end
47
+
48
+ def close
49
+ @closed = true
50
+ @sse.close
51
+ @body.close
52
+ end
53
+
54
+ def closed?
55
+ @closed
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,3 @@
1
+ module RubyAPI
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,64 @@
1
+ module RubyAPI
2
+ class WebSocket
3
+ attr_reader :env, :path
4
+
5
+ def initialize(env, &handler)
6
+ @env = env
7
+ @handler = handler
8
+ @messages = Queue.new
9
+ @closed = false
10
+ @on_message = nil
11
+ @on_close = nil
12
+ @on_open = nil
13
+ end
14
+
15
+ def on_open(&block)
16
+ @on_open = block
17
+ end
18
+
19
+ def on_message(&block)
20
+ @on_message = block
21
+ end
22
+
23
+ def on_close(&block)
24
+ @on_close = block
25
+ end
26
+
27
+ def send(data)
28
+ @messages << { type: :send, data: data }
29
+ end
30
+
31
+ def close(code = 1000, reason = "")
32
+ @messages << { type: :close, code: code, reason: reason }
33
+ @closed = true
34
+ end
35
+
36
+ def closed?
37
+ @closed
38
+ end
39
+
40
+ def receive
41
+ msg = @messages.pop
42
+ msg
43
+ end
44
+
45
+ def hijack?
46
+ true
47
+ end
48
+
49
+ def process!
50
+ @on_open&.call(self)
51
+ @handler.call(self) if @handler
52
+ rescue => e
53
+ @on_close&.call(e) if @on_close
54
+ ensure
55
+ @on_close&.call(nil) unless @closed
56
+ end
57
+
58
+ def self.upgrade(env)
59
+ io = env["rack.hijack"]&.call
60
+ return nil unless io
61
+ io
62
+ end
63
+ end
64
+ end