macaw_framework 0.1.5 → 1.0.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 +4 -4
- data/.rubocop.yml +8 -2
- data/CHANGELOG.md +17 -0
- data/Gemfile +7 -1
- data/README.md +50 -9
- data/lib/macaw_framework/aspects/cache_aspect.rb +26 -0
- data/lib/macaw_framework/aspects/logging_aspect.rb +11 -3
- data/lib/macaw_framework/aspects/prometheus_aspect.rb +28 -0
- data/lib/macaw_framework/core/server.rb +144 -0
- data/lib/macaw_framework/data_filters/response_data_filter.rb +31 -0
- data/lib/macaw_framework/errors/too_many_requests_error.rb +4 -0
- data/lib/macaw_framework/middlewares/caching_middleware.rb +24 -0
- data/lib/macaw_framework/middlewares/prometheus_middleware.rb +48 -0
- data/lib/macaw_framework/middlewares/rate_limiter_middleware.rb +31 -0
- data/lib/macaw_framework/version.rb +1 -1
- data/lib/macaw_framework.rb +34 -19
- data/sig/caching_middleware.rbs +5 -0
- data/sig/macaw_framework/macaw.rbs +12 -2
- data/sig/server.rbs +6 -1
- metadata +28 -6
- data/lib/macaw_framework/middlewares/server.rb +0 -85
- /data/lib/macaw_framework/{middlewares → data_filters}/request_data_filtering.rb +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3b35cc5b2fba0adc8ded0f76db41699323cc3957d54382d19e554d0e3dc77f51
|
4
|
+
data.tar.gz: bcd983f6afe8b8864b5d2c012c120ead8290b521ed66f49477f8defb7eed2ede
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9a7f7ef95d76e906b706a036d8cb299ba1b5e3be607ba8cdc13da216b11665b019635bca062ac83fc3067a18ab36148df12d3af2bdbc0fbe42fa3a8e0cb2c018
|
7
|
+
data.tar.gz: 56a97dff9ddac5f4142b82d441de3264d4044ace0f8f9496a2e0f49a8e384c6cf57ed705bcf68dd051c536b0dc4e4b9a643161f6563a7bcfe095f2b6f691e5ff
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -24,3 +24,20 @@
|
|
24
24
|
- Adding log by aspect on endpoint calls to improve observability
|
25
25
|
- Moving the server for a new separate class to respect single responsibility
|
26
26
|
- Improved the data filtering middleware to sanitize inputs
|
27
|
+
|
28
|
+
## [0.1.5] - 2023-04-16
|
29
|
+
|
30
|
+
- Adding support to path variables
|
31
|
+
|
32
|
+
## [0.2.0] - 2023-04-22
|
33
|
+
|
34
|
+
- Adding middleware for integration with Prometheus to collect metrics
|
35
|
+
- Adding a simple caching mechanism that can be enabled separately for each endpoint
|
36
|
+
- Performance and functional optimizations
|
37
|
+
|
38
|
+
## [1.0.0] - 2023-04-28
|
39
|
+
|
40
|
+
- Adding support to HTTPS/SSL using security certificates
|
41
|
+
- Implemented a middleware for rate limiting to prevent DoS attacks
|
42
|
+
- Improvement of caching strategy to ignore optional headers
|
43
|
+
- First production-ready version
|
data/Gemfile
CHANGED
@@ -11,4 +11,10 @@ gem "minitest", "~> 5.0"
|
|
11
11
|
|
12
12
|
gem "rubocop", "~> 1.21"
|
13
13
|
|
14
|
-
gem "
|
14
|
+
gem "prometheus-client", "~> 4.1"
|
15
|
+
|
16
|
+
group :test do
|
17
|
+
gem "simplecov", "~> 0.21.2"
|
18
|
+
gem "simplecov-json"
|
19
|
+
gem "simplecov_json_formatter", "~> 0.1.2"
|
20
|
+
end
|
data/README.md
CHANGED
@@ -1,9 +1,7 @@
|
|
1
1
|
# MacawFramework
|
2
2
|
|
3
|
-
<img src="macaw_logo.png" alt= “” style="width: 30%;height: 30%;margin-left: 35%">
|
4
|
-
|
5
3
|
This is a framework for developing web applications. Please have in mind that this is still a work in progress and
|
6
|
-
it is strongly advised to not use it for production purposes for now.
|
4
|
+
it is strongly advised to not use it for production purposes for now. Actually it supports only HTTP. and HTTPS/SSL
|
7
5
|
support will be implemented soon. Anyone who wishes to contribute is welcome.
|
8
6
|
|
9
7
|
## Installation
|
@@ -19,7 +17,6 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
19
17
|
## Usage
|
20
18
|
|
21
19
|
The usage of the framework still very simple. Actually it support 5 HTTP verbs: GET, POST, PUT, PATCH and DELETE.
|
22
|
-
For now, the framework can't resolve client request body and headers. The support for this will be included soon.
|
23
20
|
|
24
21
|
The default server port is 8080. To choose a different port, create a file with the name `application.json`
|
25
22
|
in the same directory of the script that will start the application with the following content:
|
@@ -29,11 +26,43 @@ in the same directory of the script that will start the application with the fol
|
|
29
26
|
"macaw": {
|
30
27
|
"port": 8080,
|
31
28
|
"bind": "localhost",
|
32
|
-
"threads": 10
|
29
|
+
"threads": 10,
|
30
|
+
"cache": {
|
31
|
+
"cache_invalidation": 3600
|
32
|
+
},
|
33
|
+
"prometheus": {
|
34
|
+
"endpoint": "/metrics"
|
35
|
+
},
|
36
|
+
"rate_limiting": {
|
37
|
+
"window": 10,
|
38
|
+
"max_requests": 3,
|
39
|
+
"ignore_headers": [
|
40
|
+
"header-to-be-ignored-from-caching-strategy",
|
41
|
+
"another-header-to-be-ignored-from-caching-strategy"
|
42
|
+
]
|
43
|
+
},
|
44
|
+
"ssl": {
|
45
|
+
"ssl": {
|
46
|
+
"cert_file_name": "path/to/cert/file/file.crt",
|
47
|
+
"key_file_name": "path/to/cert/key/file.key"
|
48
|
+
}
|
49
|
+
}
|
33
50
|
}
|
34
51
|
}
|
35
52
|
```
|
36
53
|
|
54
|
+
Cache invalidation time should be specified in seconds. In order to enable caching, The application.json file
|
55
|
+
should exist in the app main directory and it need the `cache_invalidation` config set. It is possible to
|
56
|
+
provide a list of strings in the property `ignore_headers`. All the client headers with the same name of any
|
57
|
+
of the strings provided will be ignored from caching strategy. This is useful to exclude headers like
|
58
|
+
correlation IDs from the caching strategy.
|
59
|
+
|
60
|
+
Rate Limit window should also be specified in seconds. Rate limit will be activated only if the `rate_limiting` config
|
61
|
+
exists inside `application.json`.
|
62
|
+
|
63
|
+
If the SSL configuration is provided in the `application.json` file with valid certificate and key files, the TCP server
|
64
|
+
will be wrapped with HTTPS security using the provided certificate.
|
65
|
+
|
37
66
|
Example of usage:
|
38
67
|
|
39
68
|
```ruby
|
@@ -42,20 +71,32 @@ require 'json'
|
|
42
71
|
|
43
72
|
m = MacawFramework::Macaw.new
|
44
73
|
|
45
|
-
m.get('/hello_world') do |context|
|
74
|
+
m.get('/hello_world', cache: true) do |context|
|
75
|
+
context[:body] # Returns the request body as string
|
76
|
+
context[:params] # Returns query parameters and path variables as a hash
|
77
|
+
context[:headers] # Returns headers as a hash
|
78
|
+
return JSON.pretty_generate({ hello_message: 'Hello World!' }), 200, {"Content-Type" => "application/json"}
|
79
|
+
end
|
80
|
+
|
81
|
+
m.post('/hello_world/:path_variable') do |context|
|
46
82
|
context[:body] # Returns the request body as string
|
47
83
|
context[:params] # Returns query parameters and path variables as a hash
|
48
84
|
context[:headers] # Returns headers as a hash
|
85
|
+
context[:params][:path_variable] # The defined path variable can be found in :params
|
49
86
|
return JSON.pretty_generate({ hello_message: 'Hello World!' }), 200
|
50
87
|
end
|
51
88
|
|
52
89
|
m.start!
|
53
90
|
```
|
54
91
|
|
55
|
-
The
|
92
|
+
The example above starts a server and creates a GET endpoint at localhost/hello_world.
|
93
|
+
|
94
|
+
If prometheus is enabled, a get endpoint will be defined at path `/metrics` to collect prometheus metrics. This path
|
95
|
+
is configurable via the `application.json` file.
|
56
96
|
|
57
|
-
The verb methods must always return a
|
58
|
-
|
97
|
+
The verb methods must always return a string or nil (used as the response), a number corresponding to the HTTP status
|
98
|
+
code to be returned to the client and the response headers as a Hash or nil. If an endpoint doesn't return a value or
|
99
|
+
returns nil for body, status code and headers, a default 200 OK status will be sent as the response.
|
59
100
|
|
60
101
|
## Contributing
|
61
102
|
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Aspect that provide cache for the endpoints.
|
5
|
+
module CacheAspect
|
6
|
+
def call_endpoint(cache, *args)
|
7
|
+
return super(*args) unless !cache[:cache].nil? && cache[:endpoints_to_cache].include?(args[0])
|
8
|
+
|
9
|
+
cache_filtered_name = cache_name_filter(args[1], cache[:ignored_headers])
|
10
|
+
|
11
|
+
cache[:cache].mutex.synchronize do
|
12
|
+
return cache[:cache].cache[cache_filtered_name][0] unless cache[:cache].cache[cache_filtered_name].nil?
|
13
|
+
|
14
|
+
response = super(*args)
|
15
|
+
cache[:cache].cache[cache_filtered_name] = [response, Time.now]
|
16
|
+
response
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def cache_name_filter(client_data, ignored_headers)
|
23
|
+
filtered_headers = client_data[:headers].filter { |key, _value| !ignored_headers.include?(key) }
|
24
|
+
[{ body: client_data[:body], params: client_data[:params], headers: filtered_headers }].to_s.to_sym
|
25
|
+
end
|
26
|
+
end
|
@@ -8,9 +8,17 @@ require "logger"
|
|
8
8
|
# in the framework.
|
9
9
|
module LoggingAspect
|
10
10
|
def call_endpoint(logger, *args)
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
endpoint_name = args[1].split(".")[1..].join("/")
|
12
|
+
logger.info("Request received for #{endpoint_name} with arguments: #{args[2..]}")
|
13
|
+
|
14
|
+
begin
|
15
|
+
response = super(*args)
|
16
|
+
logger.info("Response for #{endpoint_name}: #{response}")
|
17
|
+
rescue StandardError => e
|
18
|
+
logger.error("Error processing #{endpoint_name}: #{e.message}\n#{e.backtrace.join("\n")}")
|
19
|
+
raise e
|
20
|
+
end
|
21
|
+
|
14
22
|
response
|
15
23
|
end
|
16
24
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Aspect that provides application metrics using prometheus.
|
5
|
+
module PrometheusAspect
|
6
|
+
def call_endpoint(prometheus_middleware, *args)
|
7
|
+
return super(*args) if prometheus_middleware.nil?
|
8
|
+
|
9
|
+
start_time = Time.now
|
10
|
+
|
11
|
+
begin
|
12
|
+
response = super(*args)
|
13
|
+
ensure
|
14
|
+
duration = (Time.now - start_time) * 1_000
|
15
|
+
|
16
|
+
endpoint_name = args[2].split(".").join("/")
|
17
|
+
|
18
|
+
prometheus_middleware.request_duration_milliseconds.with_labels(endpoint: endpoint_name).observe(duration)
|
19
|
+
prometheus_middleware.request_count.with_labels(endpoint: endpoint_name).increment
|
20
|
+
if response
|
21
|
+
prometheus_middleware.response_count.with_labels(endpoint: endpoint_name,
|
22
|
+
status: response[1]).increment
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
response
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../middlewares/rate_limiter_middleware"
|
4
|
+
require_relative "../data_filters/response_data_filter"
|
5
|
+
require_relative "../errors/too_many_requests_error"
|
6
|
+
require_relative "../aspects/prometheus_aspect"
|
7
|
+
require_relative "../aspects/logging_aspect"
|
8
|
+
require_relative "../aspects/cache_aspect"
|
9
|
+
require "openssl"
|
10
|
+
|
11
|
+
##
|
12
|
+
# Class responsible for providing a default
|
13
|
+
# webserver.
|
14
|
+
class Server
|
15
|
+
prepend CacheAspect
|
16
|
+
prepend LoggingAspect
|
17
|
+
prepend PrometheusAspect
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
19
|
+
|
20
|
+
##
|
21
|
+
# Create a new instance of Server.
|
22
|
+
# @param {Macaw} macaw
|
23
|
+
# @param {Logger} logger
|
24
|
+
# @param {Integer} port
|
25
|
+
# @param {String} bind
|
26
|
+
# @param {Integer} num_threads
|
27
|
+
# @param {CachingMiddleware} cache
|
28
|
+
# @param {Prometheus::Client:Registry} prometheus
|
29
|
+
# @return {Server}
|
30
|
+
def initialize(macaw, endpoints_to_cache = nil, cache = nil, prometheus = nil, prometheus_mw = nil)
|
31
|
+
@port = macaw.port
|
32
|
+
@bind = macaw.bind
|
33
|
+
@macaw = macaw
|
34
|
+
@macaw_log = macaw.macaw_log
|
35
|
+
@num_threads = macaw.threads
|
36
|
+
@work_queue = Queue.new
|
37
|
+
ignored_headers = set_rate_limiting
|
38
|
+
set_ssl
|
39
|
+
@rate_limit ||= nil
|
40
|
+
ignored_headers ||= nil
|
41
|
+
@cache = { cache: cache, endpoints_to_cache: endpoints_to_cache || [], ignored_headers: ignored_headers }
|
42
|
+
@prometheus = prometheus
|
43
|
+
@prometheus_middleware = prometheus_mw
|
44
|
+
@workers = []
|
45
|
+
end
|
46
|
+
|
47
|
+
# rubocop:enable Metrics/ParameterLists
|
48
|
+
|
49
|
+
##
|
50
|
+
# Start running the webserver.
|
51
|
+
def run
|
52
|
+
@server = TCPServer.new(@bind, @port)
|
53
|
+
@server = OpenSSL::SSL::SSLServer.new(@server, @context) if @context
|
54
|
+
@num_threads.times do
|
55
|
+
@workers << Thread.new do
|
56
|
+
loop do
|
57
|
+
client = @work_queue.pop
|
58
|
+
break if client == :shutdown
|
59
|
+
|
60
|
+
handle_client(client)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
loop do
|
66
|
+
@work_queue << @server.accept
|
67
|
+
rescue OpenSSL::SSL::SSLError => e
|
68
|
+
@macaw_log.error("SSL error: #{e.message}")
|
69
|
+
rescue IOError, Errno::EBADF
|
70
|
+
break
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
# Method Responsible for closing the TCP server.
|
76
|
+
def close
|
77
|
+
@server.close
|
78
|
+
@num_threads.times { @work_queue << :shutdown }
|
79
|
+
@workers.each(&:join)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def handle_client(client)
|
85
|
+
path, method_name, headers, body, parameters = RequestDataFiltering.parse_request_data(client, @macaw.routes)
|
86
|
+
raise EndpointNotMappedError unless @macaw.respond_to?(method_name)
|
87
|
+
raise TooManyRequestsError unless @rate_limit.nil? || @rate_limit.allow?(client.peeraddr[3])
|
88
|
+
|
89
|
+
client_data = get_client_data(body, headers, parameters)
|
90
|
+
|
91
|
+
@macaw_log.info("Running #{path.gsub("\n", "").gsub("\r", "")}")
|
92
|
+
message, status, response_headers = call_endpoint(@prometheus_middleware, @macaw_log, @cache,
|
93
|
+
method_name, client_data)
|
94
|
+
status ||= 200
|
95
|
+
message ||= nil
|
96
|
+
response_headers ||= nil
|
97
|
+
client.puts ResponseDataFilter.mount_response(status, response_headers, message)
|
98
|
+
rescue TooManyRequestsError
|
99
|
+
client.print "HTTP/1.1 429 Too Many Requests\r\n\r\n"
|
100
|
+
rescue EndpointNotMappedError
|
101
|
+
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
|
102
|
+
rescue StandardError => e
|
103
|
+
client.print "HTTP/1.1 500 Internal Server Error\r\n\r\n"
|
104
|
+
@macaw_log.info("Error: #{e}")
|
105
|
+
ensure
|
106
|
+
client.close
|
107
|
+
end
|
108
|
+
|
109
|
+
def set_rate_limiting
|
110
|
+
if @macaw.config&.dig("macaw", "rate_limiting")
|
111
|
+
ignored_headers = @macaw.config["macaw"]["rate_limiting"]["ignore_headers"] || []
|
112
|
+
@rate_limit = RateLimiterMiddleware.new(
|
113
|
+
@macaw.config["macaw"]["rate_limiting"]["window"].to_i || 1,
|
114
|
+
@macaw.config["macaw"]["rate_limiting"]["max_requests"].to_i || 60
|
115
|
+
)
|
116
|
+
end
|
117
|
+
ignored_headers
|
118
|
+
end
|
119
|
+
|
120
|
+
def set_ssl
|
121
|
+
if @macaw.config&.dig("macaw", "ssl")
|
122
|
+
@context = OpenSSL::SSL::SSLContext.new
|
123
|
+
@context.cert = OpenSSL::X509::Certificate.new(File.read(@macaw.config["macaw"]["ssl"]["cert_file_name"]))
|
124
|
+
@context.key = OpenSSL::PKey::RSA.new(File.read(@macaw.config["macaw"]["ssl"]["key_file_name"]))
|
125
|
+
end
|
126
|
+
@context ||= nil
|
127
|
+
rescue IOError => e
|
128
|
+
@macaw_log.error("It was not possible to read files #{@macaw.config["macaw"]["ssl"]["cert_file_name"]} and
|
129
|
+
#{@macaw.config["macaw"]["ssl"]["key_file_name"]}. Please assure the files exists and their names are correct.")
|
130
|
+
@macaw_log.error(e.backtrace)
|
131
|
+
raise e
|
132
|
+
end
|
133
|
+
|
134
|
+
def call_endpoint(name, client_data)
|
135
|
+
@macaw.send(
|
136
|
+
name.to_sym,
|
137
|
+
{ headers: client_data[:headers], body: client_data[:body], params: client_data[:parameters] }
|
138
|
+
)
|
139
|
+
end
|
140
|
+
|
141
|
+
def get_client_data(body, headers, parameters)
|
142
|
+
{ body: body, headers: headers, parameters: parameters }
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../utils/http_status_code"
|
4
|
+
|
5
|
+
##
|
6
|
+
# Module responsible to filter and mount HTTP responses
|
7
|
+
module ResponseDataFilter
|
8
|
+
include HttpStatusCode
|
9
|
+
|
10
|
+
def self.mount_response(status, headers, body)
|
11
|
+
"#{mount_first_response_line(status, headers)}#{mount_response_headers(headers)}#{body}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.mount_first_response_line(status, headers)
|
15
|
+
separator = " \r\n\r\n"
|
16
|
+
separator = " \r\n" unless headers.nil?
|
17
|
+
|
18
|
+
"HTTP/1.1 #{status} #{HTTP_STATUS_CODE_MAP[status]}#{separator}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.mount_response_headers(headers)
|
22
|
+
return nil if headers.nil?
|
23
|
+
|
24
|
+
response = ""
|
25
|
+
headers.each do |key, value|
|
26
|
+
response += "#{key}: #{value}\r\n"
|
27
|
+
end
|
28
|
+
response += "\r\n\r\n"
|
29
|
+
response
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Middleware responsible for storing and
|
5
|
+
# invalidating cache.
|
6
|
+
class CachingMiddleware
|
7
|
+
attr_accessor :cache, :mutex
|
8
|
+
|
9
|
+
def initialize(inv_time_seconds = 3_600)
|
10
|
+
@cache = {}
|
11
|
+
@mutex = Mutex.new
|
12
|
+
Thread.new do
|
13
|
+
loop do
|
14
|
+
sleep(1)
|
15
|
+
@mutex.synchronize do
|
16
|
+
@cache.each_pair do |key, value|
|
17
|
+
@cache.delete(key) if Time.now - value[1] >= inv_time_seconds
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
sleep(2)
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "prometheus/client"
|
4
|
+
require "prometheus/client/formats/text"
|
5
|
+
|
6
|
+
##
|
7
|
+
# Middleware responsible to configure prometheus
|
8
|
+
# defining metrics and an endpoint to access them.
|
9
|
+
class PrometheusMiddleware
|
10
|
+
attr_accessor :request_duration_milliseconds, :request_count, :response_count
|
11
|
+
|
12
|
+
def configure_prometheus(prometheus_registry, configurations, macaw)
|
13
|
+
return nil unless prometheus_registry
|
14
|
+
|
15
|
+
@request_duration_milliseconds = Prometheus::Client::Histogram.new(
|
16
|
+
:request_duration_milliseconds,
|
17
|
+
docstring: "The duration of each request in milliseconds",
|
18
|
+
labels: [:endpoint],
|
19
|
+
buckets: (100..1000).step(100).to_a + (2000..10_000).step(1000).to_a
|
20
|
+
)
|
21
|
+
|
22
|
+
@request_count = Prometheus::Client::Counter.new(
|
23
|
+
:request_count,
|
24
|
+
docstring: "The total number of requests received",
|
25
|
+
labels: [:endpoint]
|
26
|
+
)
|
27
|
+
|
28
|
+
@response_count = Prometheus::Client::Counter.new(
|
29
|
+
:response_count,
|
30
|
+
docstring: "The total number of responses sent",
|
31
|
+
labels: %i[endpoint status]
|
32
|
+
)
|
33
|
+
|
34
|
+
prometheus_registry.register(@request_duration_milliseconds)
|
35
|
+
prometheus_registry.register(@request_count)
|
36
|
+
prometheus_registry.register(@response_count)
|
37
|
+
prometheus_endpoint(prometheus_registry, configurations, macaw)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def prometheus_endpoint(prometheus_registry, configurations, macaw)
|
43
|
+
endpoint = configurations["macaw"]["prometheus"]["endpoint"] || "/metrics"
|
44
|
+
macaw.get(endpoint) do |_context|
|
45
|
+
[Prometheus::Client::Formats::Text.marshal(prometheus_registry), 200, { "Content-Type" => "plaintext" }]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Middleware responsible for implementing
|
5
|
+
# rate limiting
|
6
|
+
class RateLimiterMiddleware
|
7
|
+
attr_reader :window_size, :max_requests
|
8
|
+
|
9
|
+
def initialize(window_size, max_requests)
|
10
|
+
@window_size = window_size
|
11
|
+
@max_requests = max_requests
|
12
|
+
@client_timestamps = Hash.new { |key, value| key[value] = [] }
|
13
|
+
@mutex = Mutex.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def allow?(client_id)
|
17
|
+
@mutex.synchronize do
|
18
|
+
now = Time.now.to_i
|
19
|
+
timestamps = @client_timestamps[client_id]
|
20
|
+
|
21
|
+
timestamps.reject! { |timestamp| timestamp <= now - window_size }
|
22
|
+
|
23
|
+
if timestamps.length < max_requests
|
24
|
+
timestamps << now
|
25
|
+
true
|
26
|
+
else
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/macaw_framework.rb
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "macaw_framework/errors/endpoint_not_mapped_error"
|
4
|
-
require_relative "macaw_framework/middlewares/
|
5
|
-
require_relative "macaw_framework/
|
4
|
+
require_relative "macaw_framework/middlewares/prometheus_middleware"
|
5
|
+
require_relative "macaw_framework/data_filters/request_data_filtering"
|
6
|
+
require_relative "macaw_framework/middlewares/caching_middleware"
|
7
|
+
require_relative "macaw_framework/core/server"
|
6
8
|
require_relative "macaw_framework/version"
|
9
|
+
require "prometheus/client"
|
7
10
|
require "logger"
|
8
11
|
require "socket"
|
9
12
|
require "json"
|
@@ -15,7 +18,7 @@ module MacawFramework
|
|
15
18
|
class Macaw
|
16
19
|
##
|
17
20
|
# Array containing the routes defined in the application
|
18
|
-
attr_reader :routes
|
21
|
+
attr_reader :routes, :port, :bind, :threads, :macaw_log, :config
|
19
22
|
|
20
23
|
##
|
21
24
|
# @param {Logger} custom_log
|
@@ -23,17 +26,27 @@ module MacawFramework
|
|
23
26
|
begin
|
24
27
|
@routes = []
|
25
28
|
@macaw_log ||= custom_log.nil? ? Logger.new($stdout) : custom_log
|
26
|
-
config = JSON.parse(File.read("application.json"))
|
27
|
-
@port = config["macaw"]["port"]
|
28
|
-
@bind = config["macaw"]["bind"]
|
29
|
-
@threads = config["macaw"]["threads"]
|
29
|
+
@config = JSON.parse(File.read("application.json"))
|
30
|
+
@port = @config["macaw"]["port"] || 8080
|
31
|
+
@bind = @config["macaw"]["bind"] || "localhost"
|
32
|
+
@threads = @config["macaw"]["threads"] || 5
|
33
|
+
unless @config["macaw"]["cache"].nil?
|
34
|
+
@cache = CachingMiddleware.new(@config["macaw"]["cache"]["cache_invalidation"].to_i || 3_600)
|
35
|
+
end
|
36
|
+
@prometheus = Prometheus::Client::Registry.new if @config["macaw"]["prometheus"]
|
37
|
+
@prometheus_middleware = PrometheusMiddleware.new if @config["macaw"]["prometheus"]
|
38
|
+
@prometheus_middleware.configure_prometheus(@prometheus, @config, self) if @config["macaw"]["prometheus"]
|
30
39
|
rescue StandardError => e
|
31
40
|
@macaw_log.error(e.message)
|
32
41
|
end
|
33
42
|
@port ||= 8080
|
34
43
|
@bind ||= "localhost"
|
44
|
+
@config ||= nil
|
35
45
|
@threads ||= 5
|
36
|
-
@
|
46
|
+
@endpoints_to_cache = []
|
47
|
+
@prometheus ||= nil
|
48
|
+
@prometheus_middleware ||= nil
|
49
|
+
@server = server.new(self, @endpoints_to_cache, @cache, @prometheus, @prometheus_middleware)
|
37
50
|
end
|
38
51
|
|
39
52
|
##
|
@@ -42,18 +55,19 @@ module MacawFramework
|
|
42
55
|
# @param {String} path
|
43
56
|
# @param {Proc} block
|
44
57
|
# @return {Integer, String}
|
45
|
-
def get(path, &block)
|
46
|
-
map_new_endpoint("get", path, &block)
|
58
|
+
def get(path, cache: false, &block)
|
59
|
+
map_new_endpoint("get", cache, path, &block)
|
47
60
|
end
|
48
61
|
|
49
62
|
##
|
50
63
|
# Creates a POST endpoint associated
|
51
64
|
# with the respective path.
|
52
65
|
# @param {String} path
|
66
|
+
# @param {Boolean} cache
|
53
67
|
# @param {Proc} block
|
54
68
|
# @return {String, Integer}
|
55
|
-
def post(path, &block)
|
56
|
-
map_new_endpoint("post", path, &block)
|
69
|
+
def post(path, cache: false, &block)
|
70
|
+
map_new_endpoint("post", cache, path, &block)
|
57
71
|
end
|
58
72
|
|
59
73
|
##
|
@@ -62,8 +76,8 @@ module MacawFramework
|
|
62
76
|
# @param {String} path
|
63
77
|
# @param {Proc} block
|
64
78
|
# @return {String, Integer}
|
65
|
-
def put(path, &block)
|
66
|
-
map_new_endpoint("put", path, &block)
|
79
|
+
def put(path, cache: false, &block)
|
80
|
+
map_new_endpoint("put", cache, path, &block)
|
67
81
|
end
|
68
82
|
|
69
83
|
##
|
@@ -72,8 +86,8 @@ module MacawFramework
|
|
72
86
|
# @param {String} path
|
73
87
|
# @param {Proc} block
|
74
88
|
# @return {String, Integer}
|
75
|
-
def patch(path, &block)
|
76
|
-
map_new_endpoint("patch", path, &block)
|
89
|
+
def patch(path, cache: false, &block)
|
90
|
+
map_new_endpoint("patch", cache, path, &block)
|
77
91
|
end
|
78
92
|
|
79
93
|
##
|
@@ -82,8 +96,8 @@ module MacawFramework
|
|
82
96
|
# @param {String} path
|
83
97
|
# @param {Proc} block
|
84
98
|
# @return {String, Integer}
|
85
|
-
def delete(path, &block)
|
86
|
-
map_new_endpoint("delete", path, &block)
|
99
|
+
def delete(path, cache: false, &block)
|
100
|
+
map_new_endpoint("delete", cache, path, &block)
|
87
101
|
end
|
88
102
|
|
89
103
|
##
|
@@ -106,7 +120,8 @@ module MacawFramework
|
|
106
120
|
server.run
|
107
121
|
end
|
108
122
|
|
109
|
-
def map_new_endpoint(prefix, path, &block)
|
123
|
+
def map_new_endpoint(prefix, cache, path, &block)
|
124
|
+
@endpoints_to_cache << "#{prefix}.#{RequestDataFiltering.sanitize_method_name(path)}" if cache
|
110
125
|
path_clean = RequestDataFiltering.extract_path(path)
|
111
126
|
@macaw_log.info("Defining #{prefix.upcase} endpoint at /#{path}")
|
112
127
|
define_singleton_method("#{prefix}.#{path_clean}", block || lambda {
|
@@ -1,15 +1,25 @@
|
|
1
1
|
module MacawFramework
|
2
2
|
class Macaw
|
3
|
-
@bind:
|
3
|
+
@bind: String
|
4
|
+
@cache: untyped
|
5
|
+
@config: Hash[String, untyped]
|
6
|
+
@endpoints_to_cache: Array[String]
|
4
7
|
@macaw_log: Logger
|
5
|
-
@port: int
|
6
8
|
|
9
|
+
@prometheus: untyped
|
10
|
+
@prometheus_middleware: untyped
|
7
11
|
@server: Server
|
8
12
|
|
9
13
|
@threads: Integer
|
10
14
|
|
15
|
+
attr_reader bind: String
|
16
|
+
attr_reader config: Hash[String, untyped]
|
17
|
+
attr_reader macaw_log: Logger
|
18
|
+
attr_reader port: Integer
|
11
19
|
attr_reader routes: Array[String]
|
12
20
|
|
21
|
+
attr_reader threads: Integer
|
22
|
+
|
13
23
|
def delete: -> nil
|
14
24
|
|
15
25
|
def get: -> nil
|
data/sig/server.rbs
CHANGED
@@ -1,11 +1,16 @@
|
|
1
1
|
class Server
|
2
2
|
@bind: String
|
3
|
+
@cache: Hash[Symbol, Array]
|
4
|
+
@context: OpenSSL::SSL::SSLContext
|
5
|
+
@endpoints_to_cache: Array[String]
|
3
6
|
@macaw: MacawFramework::Macaw
|
4
7
|
@macaw_log: Logger
|
5
8
|
@num_threads: Integer
|
6
9
|
@port: Integer
|
7
10
|
|
8
|
-
@
|
11
|
+
@prometheus: untyped
|
12
|
+
@prometheus_middleware: untyped
|
13
|
+
@server: TCPServer|OpenSSL::SSL::SSLServer
|
9
14
|
|
10
15
|
@threads: Integer
|
11
16
|
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: macaw_framework
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Aria Diniz
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-04-
|
12
|
-
dependencies:
|
11
|
+
date: 2023-04-28 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: prometheus-client
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.1'
|
13
27
|
description: A project started for study purpose that I intend to keep working on.
|
14
28
|
email:
|
15
29
|
- aria.diniz.dev@gmail.com
|
@@ -25,13 +39,21 @@ files:
|
|
25
39
|
- README.md
|
26
40
|
- Rakefile
|
27
41
|
- lib/macaw_framework.rb
|
42
|
+
- lib/macaw_framework/aspects/cache_aspect.rb
|
28
43
|
- lib/macaw_framework/aspects/logging_aspect.rb
|
44
|
+
- lib/macaw_framework/aspects/prometheus_aspect.rb
|
45
|
+
- lib/macaw_framework/core/server.rb
|
46
|
+
- lib/macaw_framework/data_filters/request_data_filtering.rb
|
47
|
+
- lib/macaw_framework/data_filters/response_data_filter.rb
|
29
48
|
- lib/macaw_framework/errors/endpoint_not_mapped_error.rb
|
30
|
-
- lib/macaw_framework/
|
31
|
-
- lib/macaw_framework/middlewares/
|
49
|
+
- lib/macaw_framework/errors/too_many_requests_error.rb
|
50
|
+
- lib/macaw_framework/middlewares/caching_middleware.rb
|
51
|
+
- lib/macaw_framework/middlewares/prometheus_middleware.rb
|
52
|
+
- lib/macaw_framework/middlewares/rate_limiter_middleware.rb
|
32
53
|
- lib/macaw_framework/utils/http_status_code.rb
|
33
54
|
- lib/macaw_framework/version.rb
|
34
55
|
- macaw_logo.png
|
56
|
+
- sig/caching_middleware.rbs
|
35
57
|
- sig/http_status_code.rbs
|
36
58
|
- sig/logging_aspect.rbs
|
37
59
|
- sig/macaw_framework.rbs
|
@@ -60,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
82
|
- !ruby/object:Gem::Version
|
61
83
|
version: '0'
|
62
84
|
requirements: []
|
63
|
-
rubygems_version: 3.4.
|
85
|
+
rubygems_version: 3.4.12
|
64
86
|
signing_key:
|
65
87
|
specification_version: 4
|
66
88
|
summary: A web framework still in development.
|
@@ -1,85 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative "../aspects/logging_aspect"
|
4
|
-
require_relative "../utils/http_status_code"
|
5
|
-
|
6
|
-
##
|
7
|
-
# Class responsible for providing a default
|
8
|
-
# webserver.
|
9
|
-
class Server
|
10
|
-
prepend LoggingAspect
|
11
|
-
include HttpStatusCode
|
12
|
-
|
13
|
-
##
|
14
|
-
# Create a new instance of Server.
|
15
|
-
# @param {Macaw} macaw
|
16
|
-
# @param {Logger} logger
|
17
|
-
# @param {Integer} port
|
18
|
-
# @param {String} bind
|
19
|
-
# @param {Integer} num_threads
|
20
|
-
# @return {Server}
|
21
|
-
def initialize(macaw, logger, port, bind, num_threads)
|
22
|
-
@port = port
|
23
|
-
@bind = bind
|
24
|
-
@macaw = macaw
|
25
|
-
@macaw_log = logger
|
26
|
-
@num_threads = num_threads
|
27
|
-
@work_queue = Queue.new
|
28
|
-
@workers = []
|
29
|
-
end
|
30
|
-
|
31
|
-
##
|
32
|
-
# Start running the webserver.
|
33
|
-
def run
|
34
|
-
@server = TCPServer.new(@bind, @port)
|
35
|
-
@num_threads.times do
|
36
|
-
@workers << Thread.new do
|
37
|
-
loop do
|
38
|
-
client = @work_queue.pop
|
39
|
-
break if client == :shutdown
|
40
|
-
|
41
|
-
handle_client(client)
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
loop do
|
47
|
-
@work_queue << @server.accept
|
48
|
-
rescue IOError, Errno::EBADF
|
49
|
-
break
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
##
|
54
|
-
# Method Responsible for closing the TCP server.
|
55
|
-
def close
|
56
|
-
@server.close
|
57
|
-
@num_threads.times { @work_queue << :shutdown }
|
58
|
-
@workers.each(&:join)
|
59
|
-
end
|
60
|
-
|
61
|
-
private
|
62
|
-
|
63
|
-
def handle_client(client)
|
64
|
-
path, method_name, headers, body, parameters = RequestDataFiltering.parse_request_data(client, @macaw.routes)
|
65
|
-
raise EndpointNotMappedError unless @macaw.respond_to?(method_name)
|
66
|
-
|
67
|
-
@macaw_log.info("Running #{path.gsub("\n", "").gsub("\r", "")}")
|
68
|
-
message, status = call_endpoint(@macaw_log, method_name, headers, body, parameters)
|
69
|
-
status ||= 200
|
70
|
-
message ||= "Ok"
|
71
|
-
client.puts "HTTP/1.1 #{status} #{HTTP_STATUS_CODE_MAP[status]} \r\n\r\n#{message}"
|
72
|
-
client.close
|
73
|
-
rescue EndpointNotMappedError
|
74
|
-
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
|
75
|
-
client.close
|
76
|
-
rescue StandardError => e
|
77
|
-
client.print "HTTP/1.1 500 Internal Server Error\r\n\r\n"
|
78
|
-
@macaw_log.info("Error: #{e}")
|
79
|
-
client.close
|
80
|
-
end
|
81
|
-
|
82
|
-
def call_endpoint(name, headers, body, parameters)
|
83
|
-
@macaw.send(name.to_sym, { headers: headers, body: body, params: parameters })
|
84
|
-
end
|
85
|
-
end
|
File without changes
|