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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 13e1e2010580def947129c3b9c5185d2a8ef6286fb1d507c80f330d8f8d796da
4
+ data.tar.gz: 9b9e53ad61201f2c3521f62bf551926c1dd2039d94153314d8e415bf9ee7d73e
5
+ SHA512:
6
+ metadata.gz: 5f23bede7eb0a07968893f19eaf2d32326638d1bb6be94f87337f3090c63be615aa46de939a8fc4149b91f6c2fc467d9377038d825bf454755fb0eb379447f6c
7
+ data.tar.gz: 9a18768236867b0e470ed56adc2a09e9021656230648c9b01eaa52e9c1c58b1c77b5024de4229dcc2845536f04ef805aff8136e49e3cf4b652853afe48678f7f
data/bin/rubyapi ADDED
@@ -0,0 +1,2 @@
1
+ require_relative "../lib/rubyapi/cli"
2
+ RubyAPI::CLI.invoke(ARGV)
@@ -0,0 +1,162 @@
1
+ require "fileutils"
2
+ require_relative "version"
3
+
4
+ module RubyAPI
5
+ class CLI
6
+ def self.invoke(args)
7
+ command = args[0]
8
+ name = args[1]
9
+
10
+ case command
11
+ when "new"
12
+ new_project(name)
13
+ when "server"
14
+ start_server
15
+ when "version"
16
+ puts "rubyapi #{VERSION}"
17
+ else
18
+ puts "Usage: rubyapi <command> [options]"
19
+ puts ""
20
+ puts "Commands:"
21
+ puts " new <name> Create a new RubyAPI project"
22
+ puts " server Start the server (Falcon/Puma)"
23
+ puts " version Show version"
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ def self.new_project(name)
29
+ puts "Creating new RubyAPI project: #{name}"
30
+
31
+ FileUtils.mkdir_p(name)
32
+ FileUtils.mkdir_p("#{name}/app")
33
+ FileUtils.mkdir_p("#{name}/config/environments")
34
+ FileUtils.mkdir_p("#{name}/public")
35
+ FileUtils.mkdir_p("#{name}/spec/unit")
36
+ FileUtils.mkdir_p("#{name}/spec/requests")
37
+
38
+ File.write("#{name}/Gemfile", gemfile_content(name))
39
+ File.write("#{name}/config.ru", config_ru_content)
40
+ File.write("#{name}/app/main.rb", main_rb_content)
41
+ File.write("#{name}/app/routes.rb", routes_rb_content)
42
+ File.write("#{name}/.rspec", ".rspec")
43
+ File.write("#{name}/spec/spec_helper.rb", spec_helper_content)
44
+ File.write("#{name}/spec/requests/hello_spec.rb", hello_spec_content)
45
+ File.write("#{name}/README.md", readme_content(name))
46
+
47
+ puts "Done! cd #{name} && bundle install && rubyapi server"
48
+ end
49
+
50
+ def self.start_server
51
+ require_relative "../../config.ru"
52
+ end
53
+
54
+ private
55
+
56
+ def self.gemfile_content(name)
57
+ <<~GEMFILE
58
+ source "https://rubygems.org"
59
+
60
+ gem "fastrb", "~> #{RubyAPI::VERSION}"
61
+ gem "rackup"
62
+ gem "puma"
63
+ GEMFILE
64
+ end
65
+
66
+ def self.config_ru_content
67
+ <<~RUBY
68
+ require_relative "app/main"
69
+ require_relative "app/routes"
70
+
71
+ run App.new
72
+ RUBY
73
+ end
74
+
75
+ def self.main_rb_content
76
+ <<~RUBY
77
+ require "rubyapi"
78
+
79
+ class App < RubyAPI::App
80
+ def initialize
81
+ super do
82
+ # Middleware
83
+ # use MyMiddleware
84
+
85
+ # Routes
86
+ include Routes
87
+ end
88
+ end
89
+ end
90
+ RUBY
91
+ end
92
+
93
+ def self.routes_rb_content
94
+ <<~RUBY
95
+ module Routes
96
+ def self.included(base)
97
+ base.get "/hello" do
98
+ { message: "Hello World" }
99
+ end
100
+ end
101
+ end
102
+ RUBY
103
+ end
104
+
105
+ def self.spec_helper_content
106
+ <<~RUBY
107
+ require "rubyapi"
108
+ require "rack/test"
109
+
110
+ RSpec.configure do |config|
111
+ config.expect_with :rspec do |expectations|
112
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
113
+ end
114
+
115
+ config.mock_with :rspec do |mocks|
116
+ mocks.verify_partial_doubles = true
117
+ end
118
+ end
119
+ RUBY
120
+ end
121
+
122
+ def self.hello_spec_content
123
+ <<~RUBY
124
+ require "spec_helper"
125
+
126
+ RSpec.describe "GET /hello" do
127
+ include Rack::Test::Methods
128
+
129
+ def app
130
+ App.new
131
+ end
132
+
133
+ it "returns hello world" do
134
+ get "/hello"
135
+ expect(last_response.status).to eq(200)
136
+ expect(JSON.parse(last_response.body)["message"]).to eq("Hello World")
137
+ end
138
+ end
139
+ RUBY
140
+ end
141
+
142
+ def self.readme_content(name)
143
+ <<~MARKDOWN
144
+ # #{name}
145
+
146
+ A RubyAPI application.
147
+
148
+ ## Setup
149
+
150
+ bundle install
151
+
152
+ ## Running
153
+
154
+ rubyapi server
155
+
156
+ ## Testing
157
+
158
+ bundle exec rspec
159
+ MARKDOWN
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,35 @@
1
+ module RubyAPI
2
+ class Config
3
+ attr_accessor :environment, :settings
4
+
5
+ def initialize
6
+ @environment = ENV.fetch("RUBYAPI_ENV", "development")
7
+ @settings = default_settings
8
+ end
9
+
10
+ def load_environment_config(app_root = Dir.pwd)
11
+ config_file = File.join(app_root, "config", "environments", "#{@environment}.rb")
12
+ return unless File.exist?(config_file)
13
+ instance_eval(File.read(config_file))
14
+ end
15
+
16
+ def setting(key, value)
17
+ @settings[key] = value
18
+ end
19
+
20
+ def get(key)
21
+ @settings[key]
22
+ end
23
+
24
+ private
25
+
26
+ def default_settings
27
+ {
28
+ port: 3000,
29
+ host: "0.0.0.0",
30
+ log_level: "info",
31
+ secret_key: "change_me_in_production"
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,181 @@
1
+ require "json"
2
+ require "rack"
3
+
4
+ module RubyAPI
5
+ class Context
6
+ attr_reader :request, :params, :body, :session, :files
7
+ attr_accessor :response_body, :response_status, :response_headers
8
+
9
+ def initialize(request, route)
10
+ @request = request
11
+ @route = route
12
+ @params = extract_path_params
13
+ @params.merge!(extract_query_params)
14
+ @body = extract_body
15
+ @files = extract_files
16
+ @session = {}
17
+ @response_headers = {}
18
+ @store = {}
19
+ end
20
+
21
+ def session=(val)
22
+ @session = val
23
+ end
24
+
25
+ def get(key)
26
+ @store[key]
27
+ end
28
+
29
+ def set(key, value)
30
+ @store[key] = value
31
+ end
32
+
33
+ def sse(&block)
34
+ io = @request.env["rack.hijack"]&.call
35
+ return [500, {}, ["rack.hijack not available"]] unless io
36
+
37
+ stream = SSEStream.new
38
+ sse_out = SSE.new(io)
39
+
40
+ @response_headers["content-type"] = "text/event-stream"
41
+ @response_headers["cache-control"] = "no-cache"
42
+ @response_headers["connection"] = "keep-alive"
43
+ @response_status = 200
44
+
45
+ block.call(sse_out)
46
+ sse_out.close
47
+ @response_body = ""
48
+ end
49
+
50
+ def stream(&block)
51
+ body = StreamingBody.new
52
+ @response_headers["content-type"] = "text/plain"
53
+ @response_headers["transfer-encoding"] = "chunked"
54
+ @response_status = 200
55
+ @response_body = body
56
+
57
+ Thread.new do
58
+ begin
59
+ block.call(body)
60
+ ensure
61
+ body.close
62
+ end
63
+ end
64
+ end
65
+
66
+ def apply_param_types!
67
+ type_map = @route[:params]
68
+ type_map.each do |name, type|
69
+ next unless @params.key?(name)
70
+ @params[name] = RubyAPI::ParamConverters.convert(@params[name], type)
71
+ end
72
+ rescue RubyAPI::ConversionError => e
73
+ @response_status = 422
74
+ @response_body = { error: e.message }
75
+ end
76
+
77
+ def validate_body!(schema_class)
78
+ raw = @body ? (@body.is_a?(BodyProxy) ? @body.to_h : @body) : {}
79
+ validated = schema_class.validate(raw)
80
+ stringified = validated.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
81
+ @body = BodyProxy.new(stringified)
82
+ rescue Schema::ValidationError => e
83
+ @response_status = 422
84
+ @response_body = { errors: e.errors }
85
+ end
86
+
87
+ private
88
+
89
+ def extract_path_params
90
+ pattern = @route[:pattern]
91
+ names = @route[:param_names]
92
+ match = pattern.match(@request.path_info)
93
+ return {} unless match
94
+ names.each_with_index.each_with_object({}) do |(name, idx), hash|
95
+ hash[name] = match[idx + 1]
96
+ end
97
+ end
98
+
99
+ def extract_query_params
100
+ @request.params.each_with_object({}) do |(k, v), hash|
101
+ hash[k.to_sym] = v
102
+ end
103
+ end
104
+
105
+ def extract_body
106
+ return nil unless @request.post? || @request.put? || @request.patch?
107
+ return nil unless @request.content_type&.include?("application/json")
108
+
109
+ body_raw = @request.body.read
110
+ @request.body.rewind
111
+ return {} if body_raw.empty?
112
+
113
+ parsed = JSON.parse(body_raw)
114
+ BodyProxy.new(parsed)
115
+ rescue JSON::ParserError
116
+ BodyProxy.new({})
117
+ end
118
+
119
+ def extract_files
120
+ return {} unless @request.content_type&.include?("multipart/form-data")
121
+
122
+ files = {}
123
+ params = @request.env["rack.request.form_hash"]
124
+ return files unless params.is_a?(Hash)
125
+
126
+ params.each do |name, info|
127
+ next unless info.is_a?(Hash) && info[:tempfile]
128
+ files[name] = UploadedFile.new(
129
+ filename: info[:filename],
130
+ type: info[:type],
131
+ tempfile: info[:tempfile]
132
+ )
133
+ end
134
+ files
135
+ end
136
+ end
137
+
138
+ class BodyProxy
139
+ def initialize(data)
140
+ @data = data
141
+ end
142
+
143
+ def [](key)
144
+ @data[key.to_s]
145
+ end
146
+
147
+ def to_h
148
+ @data
149
+ end
150
+
151
+ def method_missing(name, *args)
152
+ if @data.key?(name.to_s)
153
+ @data[name.to_s]
154
+ else
155
+ super
156
+ end
157
+ end
158
+
159
+ def respond_to_missing?(name, include_private = false)
160
+ @data.key?(name.to_s) || super
161
+ end
162
+ end
163
+
164
+ class UploadedFile
165
+ attr_reader :filename, :type, :tempfile
166
+
167
+ def initialize(filename:, type:, tempfile:)
168
+ @filename = filename
169
+ @type = type
170
+ @tempfile = tempfile
171
+ end
172
+
173
+ def read
174
+ @tempfile.read
175
+ end
176
+
177
+ def rewind
178
+ @tempfile.rewind
179
+ end
180
+ end
181
+ end
data/lib/rubyapi/di.rb ADDED
@@ -0,0 +1,51 @@
1
+ module RubyAPI
2
+ class Container
3
+ def initialize
4
+ @registrations = {}
5
+ @instances = {}
6
+ end
7
+
8
+ def register(name, klass = nil, &block)
9
+ @registrations[name] = block || -> { klass.new }
10
+ end
11
+
12
+ def resolve(name)
13
+ return @instances[name] if @instances.key?(name)
14
+ registration = @registrations[name]
15
+ return nil unless registration
16
+ @instances[name] = registration.call
17
+ end
18
+
19
+ def registered?(name)
20
+ @registrations.key?(name)
21
+ end
22
+ end
23
+
24
+ class DependencyError < StandardError; end
25
+
26
+ module DI
27
+ def self.included(base)
28
+ base.extend(ClassMethods)
29
+ end
30
+
31
+ module ClassMethods
32
+ def inject(name)
33
+ @injected_deps ||= []
34
+ @injected_deps << name
35
+ end
36
+
37
+ def injected_deps
38
+ @injected_deps || []
39
+ end
40
+
41
+ def depends(check_class, failure_status: 401)
42
+ @dependency_checks ||= []
43
+ @dependency_checks << { check: check_class, status: failure_status }
44
+ end
45
+
46
+ def dependency_checks
47
+ @dependency_checks || []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ module RubyAPI
2
+ module ErrorHandler
3
+ def self.included(base)
4
+ base.instance_variable_set(:@error_handlers, {})
5
+ end
6
+
7
+ def self.extended(base)
8
+ base.instance_variable_set(:@error_handlers, {})
9
+ end
10
+
11
+ def rescue_from(exception_class, status: 500)
12
+ @error_handlers ||= {}
13
+ @error_handlers[exception_class] = status
14
+ end
15
+
16
+ def error_handlers
17
+ @error_handlers || {}
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,93 @@
1
+ module RubyAPI
2
+ class Job
3
+ class << self
4
+ def inherited(subclass)
5
+ subclass.instance_variable_set(:@queue, Queue.new)
6
+ subclass.instance_variable_set(:@worker_thread, nil)
7
+ subclass.instance_variable_set(:@job_results, [])
8
+ subclass.instance_variable_set(:@job_errors, [])
9
+ JobRegistry.register(subclass)
10
+ end
11
+
12
+ def enqueue(*args)
13
+ job = { class: self, args: args, enqueued_at: Time.now }
14
+ @queue << job
15
+ start_worker unless @worker_thread&.alive?
16
+ job
17
+ end
18
+
19
+ def results
20
+ @job_results.dup
21
+ end
22
+
23
+ def errors
24
+ @job_errors.dup
25
+ end
26
+
27
+ def clear_results
28
+ @job_results.clear
29
+ @job_errors.clear
30
+ end
31
+
32
+ def queue
33
+ @queue
34
+ end
35
+
36
+ def perform(*args)
37
+ raise "#{self} must implement `perform`"
38
+ end
39
+
40
+ def worker_running?
41
+ @worker_thread&.alive? || false
42
+ end
43
+
44
+ private
45
+
46
+ def start_worker
47
+ @worker_thread = Thread.new do
48
+ loop do
49
+ break if @queue.empty? && !@worker_thread&.alive?
50
+ begin
51
+ job = @queue.pop(true)
52
+ rescue ThreadError
53
+ sleep 0.01
54
+ retry
55
+ end
56
+
57
+ begin
58
+ result = job[:class].perform(*job[:args])
59
+ @job_results << {
60
+ class: job[:class].name,
61
+ args: job[:args],
62
+ result: result,
63
+ completed_at: Time.now
64
+ }
65
+ rescue => e
66
+ @job_errors << {
67
+ class: job[:class].name,
68
+ args: job[:args],
69
+ error: e.class.name,
70
+ message: e.message,
71
+ failed_at: Time.now
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ class JobRegistry
81
+ def self.jobs
82
+ @jobs ||= []
83
+ end
84
+
85
+ def self.register(klass)
86
+ jobs << klass unless jobs.include?(klass)
87
+ end
88
+
89
+ def self.find(name)
90
+ jobs.find { |j| j.name == name || j.name&.end_with?("::#{name}") }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,67 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module RubyAPI
5
+ module Middleware
6
+ class StructuredLogger
7
+ def initialize(app, output: $stdout)
8
+ @app = app
9
+ @output = output
10
+ end
11
+
12
+ def call(env)
13
+ start_time = Time.now
14
+ status, headers, body = @app.call(env)
15
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
16
+
17
+ log_entry = {
18
+ method: env["REQUEST_METHOD"],
19
+ path: env["PATH_INFO"],
20
+ status: status,
21
+ duration_ms: duration_ms,
22
+ timestamp: Time.now.iso8601
23
+ }
24
+
25
+ @output.puts(JSON.generate(log_entry))
26
+ [status, headers, body]
27
+ end
28
+ end
29
+
30
+ class Metrics
31
+ attr_reader :counts, :latencies
32
+
33
+ def initialize(app)
34
+ @app = app
35
+ @counts = Hash.new(0)
36
+ @latencies = Hash.new(0.0)
37
+ @mutex = Mutex.new
38
+ end
39
+
40
+ def call(env)
41
+ start_time = Time.now
42
+ status, headers, body = @app.call(env)
43
+ duration = Time.now - start_time
44
+
45
+ key = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
46
+ @mutex.synchronize do
47
+ @counts[key] += 1
48
+ @latencies[key] += duration
49
+ end
50
+
51
+ [status, headers, body]
52
+ end
53
+
54
+ def summary
55
+ @mutex.synchronize do
56
+ @counts.each_with_object({}) do |(key, count), hash|
57
+ total = @latencies[key]
58
+ hash[key] = {
59
+ count: count,
60
+ avg_latency_ms: (total / count * 1000).round(2)
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,50 @@
1
+ require "json"
2
+
3
+ module RubyAPI
4
+ class OpenAPI
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def generate
10
+ {
11
+ openapi: "3.0.0",
12
+ info: {
13
+ title: "RubyAPI App",
14
+ version: "0.1.0"
15
+ },
16
+ paths: generate_paths
17
+ }
18
+ end
19
+
20
+ def to_json
21
+ JSON.pretty_generate(generate)
22
+ end
23
+
24
+ private
25
+
26
+ def generate_paths
27
+ paths = {}
28
+ @app.router.routes.each do |route|
29
+ path = route[:path]
30
+ method = route[:method].downcase
31
+ paths[path] ||= {}
32
+ paths[path][method] = {
33
+ summary: "#{method.upcase} #{path}",
34
+ parameters: route[:param_names].map do |name|
35
+ {
36
+ name: name,
37
+ in: "path",
38
+ required: true,
39
+ schema: { type: "string" }
40
+ }
41
+ end,
42
+ responses: {
43
+ "200" => { description: "Success" }
44
+ }
45
+ }
46
+ end
47
+ paths
48
+ end
49
+ end
50
+ end