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 +7 -0
- data/bin/rubyapi +2 -0
- data/lib/rubyapi/cli.rb +162 -0
- data/lib/rubyapi/config.rb +35 -0
- data/lib/rubyapi/context.rb +181 -0
- data/lib/rubyapi/di.rb +51 -0
- data/lib/rubyapi/error_handler.rb +20 -0
- data/lib/rubyapi/job.rb +93 -0
- data/lib/rubyapi/middleware/logging.rb +67 -0
- data/lib/rubyapi/openapi.rb +50 -0
- data/lib/rubyapi/param_converters.rb +58 -0
- data/lib/rubyapi/plugin.rb +37 -0
- data/lib/rubyapi/plugins/auth.rb +20 -0
- data/lib/rubyapi/plugins/cache.rb +41 -0
- data/lib/rubyapi/plugins/cors.rb +35 -0
- data/lib/rubyapi/plugins/jwt.rb +42 -0
- data/lib/rubyapi/router.rb +99 -0
- data/lib/rubyapi/schema.rb +46 -0
- data/lib/rubyapi/serializer.rb +25 -0
- data/lib/rubyapi/session.rb +62 -0
- data/lib/rubyapi/sse.rb +30 -0
- data/lib/rubyapi/streaming.rb +58 -0
- data/lib/rubyapi/version.rb +3 -0
- data/lib/rubyapi/websocket.rb +64 -0
- data/lib/rubyapi.rb +306 -0
- metadata +148 -0
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
data/lib/rubyapi/cli.rb
ADDED
|
@@ -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
|
data/lib/rubyapi/job.rb
ADDED
|
@@ -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
|