macaw_framework 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46f8af6ed22b7980942f7ec3def269395d1e65e9c7bfd06a96d161c31666997d
4
- data.tar.gz: 258e71f4b693c410c325e7a7751f8a2702e3b2db814752d0de39566fa1b94576
3
+ metadata.gz: d900a18480da4792dfa03608ca8c85c0d0ba4084a3430c39008f4ca026ae784c
4
+ data.tar.gz: e3bc35f7b78bc0a6e995c0abd906879dda434e4c12306d3d55713c35dc45065c
5
5
  SHA512:
6
- metadata.gz: cc2b8fbd4d25b795df95a47025b7465b3cfb6bd340008bebdfe527232a009bad959e25f9c4528b7eb4ce98f14f6148a47dd44e56b19b4d4c23187a2b4a24b56c
7
- data.tar.gz: 0d0f265ea93a3467718cbccd2ed93d85d0d98f9c84e53a821d3071b20243ca251e7670a788bd6e848f6381cee213c16ee8164dd27a32a2a28b77de5979a23326
6
+ metadata.gz: 6ac4eb646bb2510fbc67ddec9ccfca45bea8dfcb6833f9e55b60f0f18d5edebaf392645c53dae7745eec797c926af1700c273516682bd6f10e66c2b018f1cea6
7
+ data.tar.gz: 0ecb2e16a9f1762833751bb403d824e04571c4f3b79f01355ffb6f1f29eb29c234befc759c7d76d7b939f05d5f9f3c252d8a5828198e9c7674b7854fe2edcdf1
data/.rubocop.yml CHANGED
@@ -17,3 +17,6 @@ Metrics/MethodLength:
17
17
 
18
18
  Metrics/AbcSize:
19
19
  Max: 35
20
+
21
+ Metrics/CyclomaticComplexity:
22
+ Max: 10
data/CHANGELOG.md CHANGED
@@ -18,3 +18,9 @@
18
18
  ## [0.1.3] - 2022-12-13
19
19
 
20
20
  - Adding logger gem to Macaw class to fix a bug on the application start
21
+
22
+ ## [0.1.4] - 2023-04-09
23
+
24
+ - Adding log by aspect on endpoint calls to improve observability
25
+ - Moving the server for a new separate class to respect single responsibility
26
+ - Improved the data filtering middleware to sanitize inputs
data/Gemfile CHANGED
@@ -10,3 +10,5 @@ gem "rake", "~> 13.0"
10
10
  gem "minitest", "~> 5.0"
11
11
 
12
12
  gem "rubocop", "~> 1.21"
13
+
14
+ gem "simplecov", "~> 0.22.0"
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # MacawFramework
2
2
 
3
+ <img src="macaw_logo.png" alt= “” style="width: 30%;height: 30%;margin-left: 35%">
4
+
3
5
  This is a framework for developing web applications. Please have in mind that this is still a work in progress and
4
- it is strongly advised to not use it for production purposes for now. Anyone who wishes to contribute is welcome.
6
+ it is strongly advised to not use it for production purposes for now. Actualy it supports only HTTP. HTTPS and SSL
7
+ support will be implemented soon. Anyone who wishes to contribute is welcome.
5
8
 
6
9
  ## Installation
7
10
 
@@ -24,7 +27,9 @@ in the same directory of the script that will start the application with the fol
24
27
  ```json
25
28
  {
26
29
  "macaw": {
27
- "port": 80
30
+ "port": 8080,
31
+ "bind": "localhost",
32
+ "threads": 10
28
33
  }
29
34
  }
30
35
  ```
@@ -37,7 +42,10 @@ require 'json'
37
42
 
38
43
  m = MacawFramework::Macaw.new
39
44
 
40
- m.get('/hello_world') do |headers, body, parameters|
45
+ m.get('/hello_world') do |context|
46
+ context[:body] # Returns the request body as string
47
+ context[:params] # Returns query parameters and path variables as a hash
48
+ context[:headers] # Returns headers as a hash
41
49
  return JSON.pretty_generate({ hello_message: 'Hello World!' }), 200
42
50
  end
43
51
 
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ ##
6
+ # This Aspect is responsible for logging
7
+ # the input and output of every endpoint called
8
+ # in the framework.
9
+ module LoggingAspect
10
+ def call_endpoint(logger, *args)
11
+ logger.info("Input of #{args[0]}: #{args}")
12
+ response = super(*args)
13
+ logger.info("Output of #{args[0]} #{response}")
14
+ response
15
+ end
16
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors/endpoint_not_mapped_error"
4
+
5
+ ##
6
+ # Module containing methods to filter Strings
7
+ module RequestDataFiltering
8
+ VARIABLE_PATTERN = %r{:[^/]+}.freeze
9
+
10
+ ##
11
+ # Method responsible for extracting information
12
+ # provided by the client like Headers and Body
13
+ def self.parse_request_data(client, routes)
14
+ path, parameters = extract_url_parameters(client.gets.gsub("HTTP/1.1", ""))
15
+ parameters = {} if parameters.nil?
16
+
17
+ method_name = sanitize_method_name(path)
18
+ method_name = select_path(method_name, routes, parameters)
19
+ body_first_line, headers = extract_headers(client)
20
+ body = extract_body(client, body_first_line, headers["Content-Length"].to_i)
21
+ [path, method_name, headers, body, parameters]
22
+ end
23
+
24
+ def self.select_path(method_name, routes, parameters)
25
+ return method_name if routes.include?(method_name)
26
+
27
+ selected_route = nil
28
+ routes.each do |route|
29
+ split_route = route.split(".")
30
+ split_name = method_name.split(".")
31
+
32
+ next unless split_route.length == split_name.length
33
+ next unless match_path_with_route(split_name, split_route)
34
+
35
+ selected_route = route
36
+ split_route.each_with_index do |var, index|
37
+ parameters[var[1..].to_sym] = split_name[index] if var =~ VARIABLE_PATTERN
38
+ end
39
+ break
40
+ end
41
+
42
+ raise EndpointNotMappedError if selected_route.nil?
43
+
44
+ selected_route
45
+ end
46
+
47
+ def self.match_path_with_route(split_path, split_route)
48
+ split_route.each_with_index do |var, index|
49
+ return false if var != split_path[index] && !var.match?(VARIABLE_PATTERN)
50
+ end
51
+
52
+ true
53
+ end
54
+
55
+ ##
56
+ # Method responsible for sanitizing the method name
57
+ def self.sanitize_method_name(path)
58
+ path = extract_path(path)
59
+ method_name = path.gsub("/", ".").strip.downcase
60
+ method_name.gsub!(" ", "")
61
+ method_name
62
+ end
63
+
64
+ ##
65
+ # Method responsible for extracting the path from URI
66
+ def self.extract_path(path)
67
+ path[0] == "/" ? path[1..].gsub("/", ".") : path.gsub("/", ".")
68
+ end
69
+
70
+ ##
71
+ # Method responsible for extracting the headers from request
72
+ def self.extract_headers(client)
73
+ header = client.gets.delete("\n").delete("\r")
74
+ headers = {}
75
+ while header.match(%r{[a-zA-Z0-9\-/*]*: [a-zA-Z0-9\-/*]})
76
+ split_header = header.split(":")
77
+ headers[split_header[0].strip] = split_header[1].strip
78
+ header = client.gets.delete("\n").delete("\r")
79
+ end
80
+ [header, headers]
81
+ end
82
+
83
+ ##
84
+ # Method responsible for extracting the body from request
85
+ def self.extract_body(client, body_first_line, content_length)
86
+ body = client.read(content_length)
87
+ body_first_line << body.to_s
88
+ end
89
+
90
+ ##
91
+ # Method responsible for extracting the parameters from URI
92
+ def self.extract_url_parameters(http_first_line)
93
+ return http_first_line, nil unless http_first_line =~ /\?/
94
+
95
+ path_and_parameters = http_first_line.split("?", 2)
96
+ path = "#{path_and_parameters[0]} "
97
+ parameters_array = path_and_parameters[1].split("&")
98
+ parameters_array.map! do |item|
99
+ split_item = item.split("=")
100
+ { sanitize_parameter_name(split_item[0]) => sanitize_parameter_value(split_item[1]) }
101
+ end
102
+ parameters = {}
103
+ parameters_array.each { |item| parameters.merge!(item) }
104
+ [path, parameters]
105
+ end
106
+
107
+ ##
108
+ # Method responsible for sanitizing the parameter name
109
+ def self.sanitize_parameter_name(name)
110
+ name.gsub(/[^\w\s]/, "")
111
+ end
112
+
113
+ ##
114
+ # Method responsible for sanitizing the parameter value
115
+ def self.sanitize_parameter_value(value)
116
+ value.gsub(/[^\w\s]/, "")
117
+ value.gsub(/[\r\n\s]/, "")
118
+ end
119
+ end
@@ -0,0 +1,85 @@
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MacawFramework
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "macaw_framework/endpoint_not_mapped_error"
4
- require_relative "macaw_framework/request_data_filtering"
5
- require_relative "macaw_framework/http_status_code"
3
+ require_relative "macaw_framework/errors/endpoint_not_mapped_error"
4
+ require_relative "macaw_framework/middlewares/request_data_filtering"
5
+ require_relative "macaw_framework/middlewares/server"
6
6
  require_relative "macaw_framework/version"
7
7
  require "logger"
8
8
  require "socket"
@@ -13,18 +13,27 @@ module MacawFramework
13
13
  # Class responsible for creating endpoints and
14
14
  # starting the web server.
15
15
  class Macaw
16
- include(HttpStatusCode)
16
+ ##
17
+ # Array containing the routes defined in the application
18
+ attr_reader :routes
19
+
17
20
  ##
18
21
  # @param {Logger} custom_log
19
- def initialize(custom_log = nil)
22
+ def initialize(custom_log: nil, server: Server)
20
23
  begin
24
+ @routes = []
25
+ @macaw_log ||= custom_log.nil? ? Logger.new($stdout) : custom_log
21
26
  config = JSON.parse(File.read("application.json"))
22
27
  @port = config["macaw"]["port"]
23
- rescue StandardError
24
- @port ||= 8080
28
+ @bind = config["macaw"]["bind"]
29
+ @threads = config["macaw"]["threads"].to_i
30
+ rescue StandardError => e
31
+ @macaw_log.error(e.message)
25
32
  end
26
33
  @port ||= 8080
27
- @macaw_log ||= custom_log.nil? ? Logger.new($stdout) : custom_log
34
+ @bind ||= "localhost"
35
+ @threads ||= 5
36
+ @server = server.new(self, @macaw_log, @port, @bind, @threads)
28
37
  end
29
38
 
30
39
  ##
@@ -80,46 +89,30 @@ module MacawFramework
80
89
  ##
81
90
  # Starts the web server
82
91
  def start!
92
+ @macaw_log.info("---------------------------------")
83
93
  @macaw_log.info("Starting server at port #{@port}")
84
- time = Time.now
85
- server = TCPServer.open(@port)
86
- @macaw_log.info("Server started in #{Time.now - time} seconds.")
87
- server_loop(server)
94
+ @macaw_log.info("Number of threads: #{@threads}")
95
+ @macaw_log.info("---------------------------------")
96
+ server_loop(@server)
88
97
  rescue Interrupt
89
98
  @macaw_log.info("Stopping server")
90
- server.close
99
+ @server.close
91
100
  @macaw_log.info("Macaw stop flying for some seeds...")
92
101
  end
93
102
 
94
103
  private
95
104
 
96
105
  def server_loop(server)
97
- loop do
98
- Thread.start(server.accept) do |client|
99
- path, method_name, headers, body, parameters = RequestDataFiltering.extract_client_info(client)
100
- raise EndpointNotMappedError unless respond_to?(method_name)
101
-
102
- @macaw_log.info("Running #{path.gsub("\n", "").gsub("\r", "")}")
103
- message, status = send(method_name, headers, body, parameters)
104
- status ||= 200
105
- message ||= "Ok"
106
- client.puts "HTTP/1.1 #{status} #{HTTP_STATUS_CODE_MAP[status]} \r\n\r\n#{message}"
107
- client.close
108
- rescue EndpointNotMappedError
109
- client.print "HTTP/1.1 404 Not Found\r\n\r\n"
110
- client.close
111
- rescue StandardError => e
112
- client.print "HTTP/1.1 500 Internal Server Error\r\n\r\n"
113
- @macaw_log.info("Error: #{e}")
114
- client.close
115
- end
116
- end
106
+ server.run
117
107
  end
118
108
 
119
109
  def map_new_endpoint(prefix, path, &block)
120
110
  path_clean = RequestDataFiltering.extract_path(path)
121
111
  @macaw_log.info("Defining #{prefix.upcase} endpoint at /#{path}")
122
- define_singleton_method("#{prefix}_#{path_clean}", block)
112
+ define_singleton_method("#{prefix}.#{path_clean}", block || lambda {
113
+ |context = { headers: {}, body: "", params: {} }|
114
+ })
115
+ @routes << "#{prefix}.#{path_clean}"
123
116
  end
124
117
  end
125
118
  end
data/macaw_logo.png ADDED
Binary file
@@ -0,0 +1,3 @@
1
+ module LoggingAspect
2
+ def call_endpoint: -> Array[untyped]
3
+ end
@@ -1,7 +1,15 @@
1
1
  module MacawFramework
2
2
  class Macaw
3
+ @bind: string
4
+ @macaw_log: Logger
3
5
  @port: int
4
6
 
7
+ @server: Server
8
+
9
+ @threads: Integer
10
+
11
+ attr_reader routes: Array[String]
12
+
5
13
  def delete: -> nil
6
14
 
7
15
  def get: -> nil
@@ -0,0 +1,3 @@
1
+ module RequestDataFiltering
2
+ VARIABLE_PATTERN: Regexp
3
+ end
data/sig/server.rbs ADDED
@@ -0,0 +1,25 @@
1
+ class Server
2
+ @bind: String
3
+ @macaw: MacawFramework::Macaw
4
+ @macaw_log: Logger
5
+ @num_threads: Integer
6
+ @port: Integer
7
+
8
+ @server: TCPServer
9
+
10
+ @threads: Integer
11
+
12
+ @work_queue: Thread::Queue
13
+
14
+ @workers: Array[Thread]
15
+
16
+ def close: -> nil
17
+
18
+ def run: -> nil
19
+
20
+ private
21
+
22
+ def call_endpoint: -> Array[untyped]
23
+
24
+ def handle_client: -> nil
25
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: macaw_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aria Diniz
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-12-14 00:00:00.000000000 Z
11
+ date: 2023-04-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A project started for study purpose that I intend to keep working on.
14
14
  email:
@@ -25,13 +25,19 @@ files:
25
25
  - README.md
26
26
  - Rakefile
27
27
  - lib/macaw_framework.rb
28
- - lib/macaw_framework/endpoint_not_mapped_error.rb
29
- - lib/macaw_framework/http_status_code.rb
30
- - lib/macaw_framework/request_data_filtering.rb
28
+ - lib/macaw_framework/aspects/logging_aspect.rb
29
+ - lib/macaw_framework/errors/endpoint_not_mapped_error.rb
30
+ - lib/macaw_framework/middlewares/request_data_filtering.rb
31
+ - lib/macaw_framework/middlewares/server.rb
32
+ - lib/macaw_framework/utils/http_status_code.rb
31
33
  - lib/macaw_framework/version.rb
34
+ - macaw_logo.png
32
35
  - sig/http_status_code.rbs
36
+ - sig/logging_aspect.rbs
33
37
  - sig/macaw_framework.rbs
34
38
  - sig/macaw_framework/macaw.rbs
39
+ - sig/request_data_filtering.rbs
40
+ - sig/server.rbs
35
41
  homepage: https://github.com/ariasdiniz/macaw_framework
36
42
  licenses:
37
43
  - MIT
@@ -54,7 +60,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
60
  - !ruby/object:Gem::Version
55
61
  version: '0'
56
62
  requirements: []
57
- rubygems_version: 3.3.26
63
+ rubygems_version: 3.4.10
58
64
  signing_key:
59
65
  specification_version: 4
60
66
  summary: A web framework still in development.
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- ##
4
- # Module containing methods to filter Strings
5
- module RequestDataFiltering
6
- ##
7
- # Method responsible for extracting information
8
- # provided by the client like Headers and Body
9
- def self.extract_client_info(client)
10
- path, parameters = extract_url_parameters(client.gets.gsub("HTTP/1.1", ""))
11
- method_name = path.gsub("/", "_").strip!.downcase
12
- method_name.gsub!(" ", "")
13
- body_first_line, headers = extract_headers(client)
14
- body = extract_body(client, body_first_line, headers["Content-Length"].to_i)
15
- [path, method_name, headers, body, parameters]
16
- end
17
-
18
- ##
19
- # Method responsible for extracting the path from URI
20
- def self.extract_path(path)
21
- path[0] == "/" ? path[1..].gsub("/", "_") : path.gsub("/", "_")
22
- end
23
-
24
- ##
25
- # Method responsible for extracting the headers from request
26
- def self.extract_headers(client)
27
- header = client.gets.delete("\n").delete("\r")
28
- headers = {}
29
- while header.match(%r{[a-zA-Z0-9\-/*]*: [a-zA-Z0-9\-/*]})
30
- split_header = header.split(":")
31
- headers[split_header[0]] = split_header[1][1..]
32
- header = client.gets.delete("\n").delete("\r")
33
- end
34
- [header, headers]
35
- end
36
-
37
- ##
38
- # Method responsible for extracting the body from request
39
- def self.extract_body(client, body_first_line, content_length)
40
- body = client.read(content_length)
41
- body_first_line << body.to_s
42
- end
43
-
44
- ##
45
- # Method responsible for extracting the parameters from URI
46
- def self.extract_url_parameters(http_first_line)
47
- return http_first_line, nil unless http_first_line =~ /\?/
48
-
49
- path_and_parameters = http_first_line.split("?", 2)
50
- path = "#{path_and_parameters[0]} "
51
- parameters_array = path_and_parameters[1].split("&")
52
- parameters_array.map! do |item|
53
- split_item = item.split("=")
54
- { split_item[0] => split_item[1].gsub("\n", "").gsub("\r", "").gsub("\s", "") }
55
- end
56
- parameters = {}
57
- parameters_array.each { |item| parameters.merge!(item) }
58
- [path, parameters]
59
- end
60
- end