voffie_teapot 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3887b642309860f8590ab04c04c0e4c74717259fd4c6a294c72db4f219e1bed4
4
+ data.tar.gz: 993c9a2abdc2c34665b65cb74fbe192b4fb359f9cffc69a37bb3f01338ba5a3f
5
+ SHA512:
6
+ metadata.gz: 2397220b9073b29a4232a8481f16f6de00c189f82680a0268e6cbe8bfaf3ddfd67cd6439fe55c46abc947d3c0e840f91dec9f687574a86160b91d52bccb5ab97
7
+ data.tar.gz: 99d4343ab27b38565d0b5f515605581f31d01de9c9c725cd0d2cfb5c2c9decf9830410b1f97944f341cac6847446bbadb8d6be0e0705a0e1a1f87dc388d8a37a
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'voffie_teapot/response'
4
+ require 'voffie_teapot/resource_manager'
5
+
6
+ module VoffieTeapot
7
+ # GET implementation of HTTPHandler
8
+ class GetHandler < HTTPHandler
9
+ include ResourceManager
10
+
11
+ def handle(request)
12
+ if matches?(request[:resource])
13
+ route_params = extract_params(request[:resource])
14
+ request[:params] = route_params
15
+
16
+ response = Response.new
17
+ response.change_content_type('text/html; charset=utf-8')
18
+ begin
19
+ @block.call(request, response)
20
+ return response
21
+ rescue StandardError => e
22
+ return Response.default500(e)
23
+ end
24
+ end
25
+
26
+ handle_static_resource(request)
27
+ end
28
+
29
+ private
30
+
31
+ def handle_static_resource(request)
32
+ if request[:Accept]&.include?('text/css')
33
+ handle_css(request)
34
+ elsif IMG_CONTENT_TYPES.keys.include?(request[:resource].split('.').last.to_sym)
35
+ handle_image(request)
36
+ elsif request[:resource].end_with?('.js', '.ts')
37
+ handle_script(request)
38
+ else
39
+ Response.default404(request[:resounce])
40
+ end
41
+ end
42
+
43
+ def handle_css(request)
44
+ value = css(request[:resource])
45
+ if value[:status] == 200
46
+ Response.new(200, value[:file], { 'Content-Type' => 'text/css' })
47
+ else
48
+ Response.default404(request[:resource])
49
+ end
50
+ end
51
+
52
+ def handle_image(request)
53
+ value = load_img(request[:resource])
54
+ if value[:status] == 200
55
+ response = Response.new(200, value[:file])
56
+ response.change_content_type(value[:ct])
57
+ response.change_content_length(value[:size])
58
+ response
59
+ else
60
+ Response.default404(request[:resource])
61
+ end
62
+ end
63
+
64
+ def handle_script(request)
65
+ value = load_script(request[:resource])
66
+ if value[:status] == 200
67
+ Response.new(200, value[:file], { 'Content-Type' => '*/*' })
68
+ else
69
+ Response.default404(request[:resource])
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'voffie_teapot/utils'
4
+
5
+ module VoffieTeapot
6
+ # Abstract class for HTTP method handlers
7
+ class HTTPHandler
8
+ include Utils
9
+
10
+ def initialize(path, block = nil)
11
+ @path = path
12
+ @regex = generate_reg_exp(path) if path
13
+ @block = block
14
+ @param_names = extract_param_names(path)
15
+ end
16
+
17
+ def matches?(path)
18
+ return false unless @regex
19
+
20
+ @regex.match?(path)
21
+ end
22
+
23
+ def extract_params(resource)
24
+ return {} unless resource.is_a?(String) && @path.is_a?(String)
25
+
26
+ param_names = extract_param_names(resource)
27
+ match_data = @regex.match(resource)
28
+
29
+ return {} unless param_names.any? && match_data
30
+
31
+ @param_names.zip(match_data.captures).to_h
32
+ end
33
+
34
+ def handle(request)
35
+ raise NotImplementedError, 'You must implement the `handle` method in a subclass.'
36
+ end
37
+
38
+ private
39
+
40
+ def extract_param_names(resource)
41
+ return [] unless resource.is_a?(String) && @path.is_a?(String)
42
+
43
+ path_segments = @path.split('/')
44
+ resource_segments = resource.split('/')
45
+
46
+ return [] if path_segments.size != resource_segments.size
47
+
48
+ path_segments.select { |segment| segment.start_with?(':') }.map { |param| param[1..].to_sym }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'json'
5
+ require 'voffie_teapot/parser'
6
+ require 'voffie_teapot/response'
7
+ require 'voffie_teapot/http_handler'
8
+ require 'voffie_teapot/get_handler'
9
+ require 'voffie_teapot/post_handler'
10
+
11
+ module VoffieTeapot
12
+ # Base class for Teapot gem
13
+ class Main
14
+ include Parser
15
+
16
+ def initialize(port)
17
+ @server = TCPServer.new(port)
18
+ @running = true
19
+ @routes = { 'GET' => [], 'POST' => [] }
20
+ @middleware = []
21
+
22
+ Signal.trap('INT') do
23
+ puts "\nShutting down server..."
24
+ close
25
+ end
26
+ end
27
+
28
+ def close
29
+ @running = false
30
+ @server.close
31
+ end
32
+
33
+ def listen
34
+ puts "Teapot server started. Listening on port #{@server.addr[1]}..."
35
+
36
+ while @running
37
+ socket = @server.accept
38
+ Thread.new { handle_connection(socket) }
39
+ end
40
+ end
41
+
42
+ def get(path, &block)
43
+ @routes['GET'] << GetHandler.new(path, block)
44
+ end
45
+
46
+ def post(path, &block)
47
+ @routes['POST'] << PostHandler.new(path, block)
48
+ end
49
+
50
+ def use(path, &middleware)
51
+ @middleware << { path: path, middleware: middleware }
52
+ end
53
+
54
+ def process_middleware(request, response)
55
+ matching_middleware = @middleware.select { |m| match_route(m[:path], request[:resource]) }
56
+
57
+ matching_middleware.each do |middleware|
58
+ res = middleware[:middleware].call(request, response)
59
+ return res if res.is_a?(Response)
60
+ end
61
+ end
62
+
63
+ def match_route(route, resource)
64
+ return true if route == '*'
65
+
66
+ route_segments = route.split('/')
67
+ resource_segments = resource.split('/')
68
+
69
+ return false if route_segments.length != resource_segments.length
70
+
71
+ route_segments.each_with_index do |_segments, index|
72
+ if segment.start_with?(':')
73
+ next
74
+ elsif segment != resource_segments[index]
75
+ return false
76
+ end
77
+ end
78
+
79
+ true
80
+ end
81
+
82
+ def handle_connection(socket)
83
+ request = read_request(socket)
84
+ return unless request
85
+
86
+ parsed_request = parse(request)
87
+ if parsed_request[:Content_Length].to_i.positive?
88
+ parsed_request[:body] = JSON.parse(socket.read(parsed_request[:Content_Length].to_i))
89
+ parsed_request[:body] = parsed_request[:body].transform_keys(&:to_sym)
90
+ end
91
+
92
+ response = route_request(parsed_request).create_response
93
+ socket.print response
94
+ socket.close
95
+ end
96
+
97
+ def read_request(socket)
98
+ request = ''
99
+ while (line = socket.gets) && line !~ /^\s*$/
100
+ request += line
101
+ end
102
+ request.empty? ? nil : request
103
+ end
104
+
105
+ def route_request(request)
106
+ response = Response.new
107
+ process_middleware(request, response)
108
+
109
+ return response unless response.body == ''
110
+
111
+ method = request[:method]
112
+ return Response.default404(request[:path]) unless @routes.key?(method)
113
+
114
+ handler = @routes[method].find { |h| h.matches?(request[:resource]) }
115
+ if handler
116
+ response = handler.handle(request)
117
+ return response.is_a?(Response) ? response : Response.default404(request[:path])
118
+ end
119
+
120
+ response =
121
+ case method
122
+ when 'GET'
123
+ GetHandler.new(nil).handle(request)
124
+ when 'POST'
125
+ PostHandler.new(nil).handle(request)
126
+ end
127
+
128
+ response.is_a?(Response) ? response : Response.default404(request[:path])
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ VALID_METHODS = %w[GET POST PUT DELETE PATCH].freeze
4
+
5
+ module VoffieTeapot
6
+ # Parser module to parse HTTP requests
7
+ module Parser
8
+ def parse(request)
9
+ lines = request.split("\n")
10
+ return nil if lines[0].nil?
11
+
12
+ method, resource, http = lines[0].split
13
+ parsed_request = {}
14
+
15
+ if VALID_METHODS.include?(method)
16
+ parsed_request[:method] = method
17
+ parsed_request[:resource] = resource
18
+ parsed_request[:http] = http
19
+ lines[1..].each do |row|
20
+ key, val = row.split(': ', 2)
21
+ next if key.nil? || val.nil?
22
+
23
+ parsed_request[key.gsub('-', '_').to_sym] = val.strip
24
+ end
25
+ else
26
+ parsed_request[:error] = "Invalid method: #{method}"
27
+ end
28
+ parsed_request
29
+ rescue StandardError => e
30
+ { error: "Error parsing request: #{e.message}" }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoffieTeapot
4
+ # POST implementation of HTTPHandler
5
+ class PostHandler < HTTPHandler
6
+ def handle(request)
7
+ return unless matches?(request[:resource])
8
+
9
+ response = Response.new
10
+ begin
11
+ @block.call(request, response)
12
+ rescue StandardError => e
13
+ return Response.default500(e)
14
+ end
15
+ response
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'slim'
4
+
5
+ IMG_CONTENT_TYPES = {
6
+ apng: 'image/apng', avif: 'image/avif', gif: 'image/gif',
7
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
8
+ pjpeg: 'image/jpeg', pjp: 'image/jpeg', png: 'image/png',
9
+ svg: 'image/svg+xml', webp: 'image/webp'
10
+ }.freeze
11
+
12
+ module VoffieTeapot
13
+ # Module to handle loading/generating resources
14
+ module ResourceManager
15
+ def load_slim(resource, locals = {}, layout: true)
16
+ content = read_file("./views/#{resource}.slim")
17
+ layout_content = layout ? read_file('./views/layout.slim') : nil
18
+
19
+ rendered = Slim::Template.new { content }.render(nil, locals)
20
+ layout ? Slim::Template.new { layout_content }.render { rendered } : rendered
21
+ rescue StandardError => e
22
+ "Error rendering template #{e.message}"
23
+ end
24
+
25
+ def css(resource)
26
+ load_public_file(resource, 'text/css')
27
+ end
28
+
29
+ def load_img(resource)
30
+ load_public_file(resource, IMG_CONTENT_TYPES[resource.split('.').last.to_sym])
31
+ end
32
+
33
+ def load_script(resource)
34
+ load_public_file(resource, '*/*')
35
+ end
36
+
37
+ private
38
+
39
+ def load_public_file(resource, content_type)
40
+ normalized_resource = resource.start_with?('/') ? resource[1..] : resource
41
+ path = "./public/#{normalized_resource}"
42
+ if File.file?(path)
43
+ { file: File.read(path), ct: content_type, status: 200, size: File.size(path) }
44
+ else
45
+ { status: 404 }
46
+ end
47
+ end
48
+
49
+ def read_file(path)
50
+ File.read(path)
51
+ rescue Errno::ENOENT
52
+ raise "File not found #{path}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'voffie_teapot/resource_manager'
4
+
5
+ module VoffieTeapot
6
+ # Response class to handle/generate responses
7
+ class Response
8
+ include ResourceManager
9
+ attr_accessor :body, :status, :header
10
+
11
+ def initialize(status = 200, body = '', header = {})
12
+ @status = status
13
+ @body = body
14
+ @header = header
15
+ @cookies = {}
16
+ end
17
+
18
+ def self.default404(route)
19
+ body = load_error_page('404.html', { route: route })
20
+ new(404, body, { 'Content-Type' => 'text/html' })
21
+ end
22
+
23
+ def self.default500(error)
24
+ body = load_error_page('500.html', { error_message: error.message, error_class: error.class, backtrace: error.backtrace })
25
+ new(500, body, { 'Content-Type' => 'text/html' })
26
+ end
27
+
28
+ def self.load_error_page(filename, locals = {})
29
+ user_path = "./views/#{filename}"
30
+ gem_path = File.join(Gem::Specification.find_by_name('voffie_teapot').gem_dir, 'lib', 'voffie_teapot', 'views', filename)
31
+
32
+ if File.exist?(user_path)
33
+ template = File.read(user_path)
34
+ else
35
+ return '<html><body><h1>Error loading template</h1></body></html>' unless File.exist?(gem_path)
36
+
37
+ template = File.read(gem_path)
38
+ end
39
+
40
+ locals.each do |key, value|
41
+ template.gsub!("<%= #{key} %>", value.to_s)
42
+ end
43
+
44
+ template
45
+ rescue Errno::ENOENT
46
+ '<html><body><h1>Error loading template</h1></body></html>'
47
+ end
48
+
49
+ def create_cookie(name, value)
50
+ @cookies[name] = value
51
+ end
52
+
53
+ def change_status(status)
54
+ @status = status
55
+ end
56
+
57
+ def change_content_type(type)
58
+ @header['Content-Type'] = type
59
+ end
60
+
61
+ def change_content_length(length)
62
+ @header['Content-Length'] = length.to_s
63
+ end
64
+
65
+ def create_response
66
+ headers = @header.to_a.map { |key, value| "#{key}: #{value}" }.join("\r\n")
67
+ cookies = @cookies.to_a.map { |key, value| "Set-Cookie: #{key}=#{value}" }.join("\r\n")
68
+ response = "HTTP/1.1 #{@status}\r\n#{headers}\r\n"
69
+ response += "#{cookies}\r\n" unless cookies.empty?
70
+ response + "\r\n#{@body}"
71
+ end
72
+
73
+ def redirect(location)
74
+ @header['Location'] = location
75
+ change_status(301)
76
+ end
77
+
78
+ def slim(resource, locals = {}, layout: true)
79
+ load_slim(resource, locals, layout: layout)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoffieTeapot
4
+ # Module containing all utility methods required
5
+ module Utils
6
+ def generate_reg_exp(path)
7
+ return %r{^/$} if path == '/'
8
+
9
+ Regexp.new("^#{path.split('/').map do |segment|
10
+ segment.start_with?(':') ? '([^/]+)' : Regexp.escape(segment)
11
+ end.join('/')}$")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoffieTeapot
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html lang='en'>
3
+
4
+ <head>
5
+ <meta charset='UTF-8' />
6
+ <meta name='viewport' content='width=device-width, initial-scale=1.0' />
7
+ <title>404 - Not Found</title>
8
+ <style>
9
+ body {
10
+ font-family: Arial, sans-serif;
11
+ background-color: #e0f2f1;
12
+ color: #004d40;
13
+ display: flex;
14
+ justify-content: center;
15
+ align-items: center;
16
+ height: 100vh;
17
+ margin: 0;
18
+ padding: 20px;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ .container {
23
+ text-align: center;
24
+ background-color: #fff;
25
+ border-radius: 8px;
26
+ padding: 40px;
27
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
28
+ max-width: 600px;
29
+ width: 100%;
30
+ }
31
+
32
+ h1 {
33
+ margin-top: 0;
34
+ color: #00796b;
35
+ }
36
+
37
+ .teapot {
38
+ font-size: 48px;
39
+ margin-bottom: 20px;
40
+ }
41
+ </style>
42
+ </head>
43
+
44
+ <body>
45
+ <div class="container">
46
+ <div class="teapot">🫖</div>
47
+ <h1>404 - Page Not Found</h1>
48
+ <p>Oh no! It seems you've stumbled upon an empty teacup. The page you're looking for has vanished like steam from a
49
+ hot brew.</p>
50
+ <p>Why not try steeping a fresh URL or returning to our <a href="/">homepage</a></p>
51
+ </div>
52
+ </body>
53
+
54
+ </html>
@@ -0,0 +1,68 @@
1
+ <!DOCTYPE html>
2
+ <html lang='en'>
3
+
4
+ <head>
5
+ <meta charset='UTF-8' />
6
+ <meta name='viewport' content='width=device-width, initial-scale=1.0' />
7
+ <title>500 - Internal Server Error</title>
8
+ <style>
9
+ body {
10
+ font-family: Arial, sans-serif;
11
+ background-color: #f0e6d2;
12
+ color: #5d4037;
13
+ display: flex;
14
+ justify-content: center;
15
+ align-items: center;
16
+ height: 100vh;
17
+ margin: 0;
18
+ padding: 20px;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ .container {
23
+ text-align: center;
24
+ background-color: #fff;
25
+ border-radius: 8px;
26
+ padding: 40px;
27
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
28
+ max-width: 600px;
29
+ width: 100%;
30
+ }
31
+
32
+ h1 {
33
+ margin-top: 0;
34
+ color: #d32f2f;
35
+ }
36
+
37
+ .teapot {
38
+ font-size: 48px;
39
+ margin-bottom: 20px;
40
+ }
41
+
42
+ pre {
43
+ background-color: #f5f5f5;
44
+ padding: 15px;
45
+ border-radius: 4px;
46
+ overflow-x: auto;
47
+ text-align: left;
48
+ white-space: pre-line;
49
+ }
50
+ </style>
51
+ </head>
52
+
53
+ <body>
54
+ <div class="container">
55
+ <div class="teapot">🫖</div>
56
+ <h1>500 - Internal Server Error</h1>
57
+ <p>Oops! It seems our teapot has overflowed. We're mopping up the mess and will be back shortly.</p>
58
+ <p>Error details: <strong>
59
+ <%= error_class %> - <%= error_message %>
60
+ </strong>
61
+ </p>
62
+ <pre id="error-details">
63
+ <%= backtrace %>
64
+ </pre>
65
+ </div>
66
+ </body>
67
+
68
+ </html>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'voffie_teapot/main'
4
+
5
+ # Main module for the Teapot gem
6
+ module VoffieTeapot
7
+ def self.new(...)
8
+ Main.new(...)
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: voffie_teapot
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Voffie
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-01-27 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: slim
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.2'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 5.2.1
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '5.2'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 5.2.1
32
+ description: A web server using Ruby's built-in TCPServer class & web sockets
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - lib/voffie_teapot.rb
38
+ - lib/voffie_teapot/get_handler.rb
39
+ - lib/voffie_teapot/http_handler.rb
40
+ - lib/voffie_teapot/main.rb
41
+ - lib/voffie_teapot/parser.rb
42
+ - lib/voffie_teapot/post_handler.rb
43
+ - lib/voffie_teapot/resource_manager.rb
44
+ - lib/voffie_teapot/response.rb
45
+ - lib/voffie_teapot/utils.rb
46
+ - lib/voffie_teapot/version.rb
47
+ - lib/voffie_teapot/views/404.html
48
+ - lib/voffie_teapot/views/500.html
49
+ homepage: https://github.com/voffie/teapot
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ source_code_uri: https://github.com/voffie/teapot
54
+ bug_tracker_uri: https://github.com/voffie/teapot/issues
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.7.8
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.7.0.dev
70
+ specification_version: 4
71
+ summary: TCP based web server
72
+ test_files: []