spyder 0.1.0 → 0.1.2

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: acf6ba3240fd34ee197d51298611eaf540b096593d135899c6776cd7378f2726
4
- data.tar.gz: 0035fda272f0b56c184529166654daec8d3f8b0b6757bfda5e0cbc86d4010234
3
+ metadata.gz: 3417dd82aa219dd2526c0fb01be1f6e9c58723651bac784b2f082a4c8d3b5f20
4
+ data.tar.gz: eca6629c49c0048f7304da5c71f8701d38b1a24ef4a160f923bf5267d0cea628
5
5
  SHA512:
6
- metadata.gz: 033d5355015791027499d3650cfdf94e0d3dd4db326a4cd7b461b939f73744ae461b57f5213790dd5874edbfe8bddef25cc4abf0b866e474e28a7bdb47943b19
7
- data.tar.gz: 979996a1e7761986950816d84ca25b6dead8b5450736b1c2efb2aeddc17b8357028a12f9ec293488a0175a67cdd6c1d0ebb9ead0a4ac5301e3b76e02589c2fe5
6
+ metadata.gz: d97926b4bc3a3064d7e6224c0bbb61ec2a225207f766802be5821db8d9b092eba60e92aec37b5012843e073999aa057c79e946551503e680dcf7c2e327013f32
7
+ data.tar.gz: 51a1d5d5a6b4e8d7ba2598ddcf674e1acb224e35fa77a2c53f90a97f936709bc52274f9f92cef492d93f96ef3095758a7e946e63cc2db7720859a4c654096509
@@ -0,0 +1,17 @@
1
+ require 'spyder'
2
+
3
+ server = Spyder::Server.new('0.0.0.0', 8080)
4
+
5
+ server.router.add_route 'GET', '/hello-world' do |request, _|
6
+ which_world = request.query_params['world'] || 'Earth'
7
+
8
+ resp = Spyder::Response.new
9
+ resp.add_standard_headers
10
+ resp.set_header 'content-type', 'text/plain'
11
+ resp.body = "hello from #{which_world}!"
12
+
13
+ resp
14
+ end
15
+
16
+ puts "Now navigate to http://localhost:8080/?world=Mars"
17
+ server.start
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class BaseError < ::StandardError
5
+ end
6
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module Spyder
3
+ class HeaderStore
4
+ class MissingHeaderError < ::Spyder::BaseError
5
+ end
6
+
7
+ # headers as they have been received. Untransformed and in order
8
+ attr_reader :ordered
9
+
10
+ # headers, indexed. All keys are lower case
11
+ attr_reader :dict
12
+
13
+ # either :request or :response
14
+ attr_accessor :kind
15
+
16
+ def initialize kind=nil
17
+ @kind = kind
18
+ @ordered = []
19
+ @dict = {}
20
+ end
21
+
22
+ def fetch key, default_value=nil
23
+ value = @dict.fetch(key.downcase, default_value)
24
+ return value unless value == nil
25
+ return yield(key) if block_given?
26
+ nil
27
+ end
28
+
29
+ def get! key
30
+ value = fetch key
31
+ raise MissingHeaderError.new(key) if value == nil
32
+ value
33
+ end
34
+
35
+ def add_header key, value
36
+ @ordered << [key, value]
37
+ @dict[key.downcase] = value
38
+ end
39
+
40
+ def set_header key, value
41
+ key_lower = key.downcase
42
+ if @kind == :response && key_lower == 'set-cookie'
43
+ add_header key_lower, value
44
+ else
45
+ @dict[key_lower] = value
46
+ oh_index = @ordered.find_index { |k, v| k.downcase == key_lower }
47
+ if oh_index
48
+ @ordered[oh_index][1] = value
49
+ else
50
+ @ordered << [key, value]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class Request
5
+ extend Forwardable
6
+
7
+ attr_accessor :verb
8
+ attr_accessor :path
9
+ attr_accessor :headers
10
+ attr_accessor :protocol, :remote_address
11
+ attr_accessor :io
12
+
13
+ def_delegators :@headers, :ordered, :dict, :add_header
14
+
15
+ def initialize
16
+ @headers = HeaderStore.new(:request)
17
+ end
18
+
19
+ def host_with_port
20
+ result = dict['host']&.split(':')
21
+ return nil if result == nil
22
+ result << 443 if result.length < 2
23
+ result
24
+ end
25
+
26
+ def host
27
+ h = host_with_port
28
+ return nil if h == nil
29
+ h[0]
30
+ end
31
+
32
+ def port
33
+ h = host_with_port
34
+ return nil if h == nil
35
+ h[1]
36
+ end
37
+
38
+ def path_info
39
+ separator = path.index '?'
40
+ return path if separator == nil
41
+ path[0...separator]
42
+ end
43
+
44
+ def query_string
45
+ separator = path.index '?'
46
+ return nil if separator == nil
47
+ path[separator+1..-1]
48
+ end
49
+
50
+ def query_params
51
+ return {} unless query_string
52
+ res = {}
53
+ query_string.split('&').each do |line|
54
+ name, value = line.split('=')
55
+ name = CGI.unescape(name)
56
+ value = CGI.unescape(value)
57
+
58
+ if name.end_with?('[]') && name.length > 2
59
+ name = name[..-3]
60
+ arr = res.fetch(name, [])
61
+ arr = (res[name] = [])
62
+ arr << value
63
+ else
64
+ res[name] = value
65
+ end
66
+ end
67
+
68
+ res
69
+ end
70
+
71
+ def read_full_body
72
+ len = dict['content-length']
73
+ len ? io.read(Integer(len)) : io.read # FIXME:
74
+ end
75
+
76
+ def has_body?
77
+ dict['transfer-encoding'] != nil || dict['content-length'] != nil
78
+ end
79
+
80
+ def verb_allows_body?
81
+ %w(POST PUT PATCH).include?(verb)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class Response
5
+ attr_reader :code
6
+ attr_accessor :body
7
+ attr_reader :headers
8
+ attr_writer :reason_sentence
9
+
10
+ CODE_TO_REASON_SENTENCE = {
11
+ 100 => 'Continue',
12
+ 101 => 'Switching Protocols',
13
+ 200 => 'OK',
14
+ 201 => 'Created',
15
+ 202 => 'Accepted',
16
+ 203 => 'Non-Authoritative Information',
17
+ 204 => 'No Content',
18
+ 205 => 'Reset Content',
19
+ 206 => 'Partial Content',
20
+ 300 => 'Multiple Choices',
21
+ 301 => 'Moved Permanently',
22
+ 302 => 'Found',
23
+ 303 => 'See Other',
24
+ 304 => 'Not Modified',
25
+ 305 => 'Use Proxy',
26
+ 307 => 'Temporary Redirect',
27
+ 400 => 'Bad Request',
28
+ 401 => 'Unauthorized',
29
+ 402 => 'Payment Required',
30
+ 403 => 'Forbidden',
31
+ 404 => 'Not Found',
32
+ 405 => 'Method Not Allowed',
33
+ 406 => 'Not Acceptable',
34
+ 407 => 'Proxy Authentication Required',
35
+ 408 => 'Request Timeout',
36
+ 409 => 'Conflict',
37
+ 410 => 'Gone',
38
+ 411 => 'Length Required',
39
+ 412 => 'Precondition Failed',
40
+ 413 => 'Payload Too Large',
41
+ 414 => 'URI Too Long',
42
+ 415 => 'Unsupported Media Type',
43
+ 416 => 'Range Not Satisfiable',
44
+ 417 => 'Expectation Failed',
45
+ 418 => 'I\'m a teapot',
46
+ 422 => 'Unprocessable Entity',
47
+ 426 => 'Upgrade Required',
48
+ 500 => 'Internal Server Error',
49
+ 501 => 'Not Implemented',
50
+ 502 => 'Bad Gateway',
51
+ 503 => 'Service Unavailable',
52
+ 504 => 'Gateway Timeout',
53
+ 505 => 'HTTP Version Not Supported',
54
+ }.freeze
55
+
56
+ SYMBOL_TO_CODE = CODE_TO_REASON_SENTENCE.to_a.to_h do |code, name|
57
+ [
58
+ name.downcase.tr(' ', '_').tr("'", '').to_sym,
59
+ code
60
+ ]
61
+ end.freeze
62
+
63
+ def initialize
64
+ self.code = :ok
65
+ @headers = HeaderStore.new(:response)
66
+ end
67
+
68
+ def add_standard_headers
69
+ set_header 'date', Time.now.httpdate
70
+ set_header 'server', "spyder/#{::Spyder::VERSION}"
71
+ end
72
+
73
+ def nocache!
74
+ set_header 'expires', Time.at(0).httpdate
75
+ set_header 'cache-control', 'private, no-store, no-cache'
76
+ end
77
+
78
+ def code=(value)
79
+ @code = value.is_a?(Symbol) ? SYMBOL_TO_CODE.fetch(value) : value
80
+ end
81
+
82
+ def set_header(key, value)
83
+ @headers.set_header(key, value)
84
+ end
85
+
86
+ def reason_sentence
87
+ @reason_sentence || CODE_TO_REASON_SENTENCE[@code]
88
+ end
89
+
90
+ def self.make_generic(code, payload=nil)
91
+ new.tap do |r|
92
+ code = SYMBOL_TO_CODE.fetch(code, code)
93
+ r.body = (payload || "#{code} #{CODE_TO_REASON_SENTENCE[code]}")
94
+ r.code = code
95
+ r.add_standard_headers
96
+ r.nocache!
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class Router
5
+ attr_accessor :fallback_route
6
+
7
+ def initialize
8
+ @routes = Hash.new { |k, v| k[v] = [] }
9
+ end
10
+
11
+ def add_route(verb, matcher, &handler)
12
+ matcher = Mustermann.new(matcher) if matcher.is_a?(String)
13
+
14
+ @routes[verb.to_s.upcase] << [matcher, handler]
15
+ end
16
+
17
+ def call(ctx, request)
18
+ only_path = request.path_info
19
+
20
+ handler, match_data = nil
21
+ @routes[request.verb].any? do |mt, h|
22
+ md = mt.match(only_path)
23
+ if md
24
+ handler = h
25
+ match_data = md
26
+ end
27
+ end
28
+
29
+ match_data ? handler.call(request, match_data) : Response.make_generic(:not_found)
30
+ end
31
+ end
32
+
33
+ class RouterApp
34
+ def initialize(router, _)
35
+ @router = router
36
+ end
37
+
38
+ def call(ctx, request)
39
+ @router.call(ctx, request)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class Server
5
+ attr_accessor :router
6
+
7
+ def initialize(bind, port, router: Router.new, max_threads: 4, tcp_backlog: 10)
8
+ @server = TCPServer.new(bind, port)
9
+ @tcp_backlog = tcp_backlog
10
+ @max_threads = max_threads
11
+ @middleware = []
12
+ @threads = []
13
+ @tp_sync = Mutex.new
14
+ @router = router
15
+ end
16
+
17
+ def add_middleware(callable, args)
18
+ @middleware << [callable, args]
19
+ end
20
+
21
+ def start
22
+ busy_threads = 0
23
+ @server.listen(@tcp_backlog)
24
+
25
+ loop do
26
+ time_start = Process.clock_gettime(:CLOCK_MONOTONIC, :float_second)
27
+ loop do
28
+ current_busy = @tp_sync.synchronize { busy_threads }
29
+ break if current_busy < @max_threads
30
+ sleep(0)
31
+ current_time = Process.clock_gettime(:CLOCK_MONOTONIC, :float_second)
32
+ if (current_time - time_start) > 1.0
33
+ # puts "Waiting a long time: #{(current_time - time_start)}"
34
+ sleep 0.2
35
+ end
36
+ end
37
+
38
+ client = @server.accept
39
+ @tp_sync.synchronize { busy_threads += 1 }
40
+
41
+ Thread.new do
42
+ begin
43
+ error = nil
44
+ begin
45
+ process_new_client(client)
46
+ rescue Exception => e
47
+ error = e
48
+ end
49
+
50
+ if error
51
+ puts error.full_message
52
+
53
+ response = Response.make_generic :internal_server_error
54
+ dispatch_response(client, response)
55
+ end
56
+
57
+ client.close rescue nil
58
+ ensure
59
+ @tp_sync.synchronize { busy_threads -= 1 }
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def process_request(request)
66
+ mids = @middleware + [[RouterApp, @router]]
67
+ app = nil
68
+ loop do
69
+ klass, args = mids.pop
70
+ break unless klass
71
+ app = klass.new(args, app)
72
+ end
73
+
74
+ app.call({}, request)
75
+ end
76
+
77
+ def process_new_client(socket)
78
+ verb, path, protocol = read_line(socket).split(' ')
79
+ request = Request.new
80
+ request.path = path
81
+ request.verb = verb
82
+ request.io = socket
83
+
84
+ loop do
85
+ line = read_line(socket)
86
+ break if line == ''
87
+ sep = line.index(':')
88
+ name = line[0...sep].downcase
89
+ value = line[(sep + 2)..]
90
+ request.add_header(name, value)
91
+ end
92
+
93
+ response = process_request(request)
94
+
95
+ dispatch_response(socket, response)
96
+ end
97
+
98
+ def dispatch_response(socket, response)
99
+ content_length = response.headers.dict['content-length']
100
+ if !content_length && response.body && response.body.is_a?(String)
101
+ content_length = response.body.length
102
+ end
103
+
104
+ begin
105
+ socket.write("HTTP/1.1 #{response.code} #{response.reason_sentence.b}\r\n")
106
+ response.headers.ordered.each do |name, value|
107
+ socket.write("#{name.b}: #{value.b}\r\n")
108
+ end
109
+ socket.write("connection: close\r\n") # FIXME:
110
+ socket.write("content-length: #{content_length}\r\n") if content_length
111
+ socket.write("\r\n")
112
+
113
+ if response.body
114
+ Array(response.body).each do |part|
115
+ content = part.respond_to?(:call) ? part.call : part
116
+ socket.write(content.b)
117
+ end
118
+ end
119
+ rescue Errno::EPIPE
120
+ # socket closed. So what?
121
+ socket.close rescue nil
122
+ end
123
+ end
124
+
125
+ def read_line(socket)
126
+ line_limit = 1024 * 16
127
+ buffer = String.new(capacity: 128)
128
+ almost = false
129
+ loop do
130
+ line_limit -= 1
131
+ return false unless line_limit > 0
132
+
133
+ c = socket.readchar
134
+ if !almost && c == "\r"
135
+ almost = true
136
+ elsif almost
137
+ return false unless c == "\n"
138
+ return buffer
139
+ else
140
+ buffer += c
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ VERSION = '0.1.2'
5
+ end
data/lib/spyder.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ require 'mustermann'
3
+ require 'date'
4
+ require 'time'
5
+ require 'forwardable'
6
+ require 'socket'
7
+ require 'cgi'
8
+
9
+ require 'spyder/version'
10
+ require 'spyder/error'
11
+ require 'spyder/header_store'
12
+ require 'spyder/request'
13
+ require 'spyder/response'
14
+ require 'spyder/router'
15
+ require 'spyder/server'
metadata CHANGED
@@ -1,43 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spyder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
- - André Piske
8
- autorequire:
7
+ - André D. Piske
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-08-17 00:00:00.000000000 Z
11
+ date: 2024-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: thor
14
+ name: mustermann
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.20.3
20
- type: :development
19
+ version: '3.0'
20
+ type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.20.3
27
- description: Reserved for the future
28
- email: ''
29
- executables:
30
- - spyder
26
+ version: '3.0'
27
+ description: Spyder Web
28
+ email: andrepiske@gmail.com
29
+ executables: []
31
30
  extensions: []
32
31
  extra_rdoc_files: []
33
32
  files:
34
- - bin/spyder
35
- homepage: https://rubygems.org/gems/spyder
33
+ - lib/examples/simplest_server/server.rb
34
+ - lib/spyder.rb
35
+ - lib/spyder/error.rb
36
+ - lib/spyder/header_store.rb
37
+ - lib/spyder/request.rb
38
+ - lib/spyder/response.rb
39
+ - lib/spyder/router.rb
40
+ - lib/spyder/server.rb
41
+ - lib/spyder/version.rb
42
+ homepage: https://github.com/andrepiske/spyder
36
43
  licenses:
37
44
  - MIT
38
- metadata:
39
- source_code_uri: https://github.com/andrepiske/spydergem
40
- post_install_message:
45
+ metadata: {}
46
+ post_install_message:
41
47
  rdoc_options: []
42
48
  require_paths:
43
49
  - lib
@@ -52,8 +58,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
58
  - !ruby/object:Gem::Version
53
59
  version: '0'
54
60
  requirements: []
55
- rubygems_version: 3.0.6
56
- signing_key:
61
+ rubygems_version: 3.5.6
62
+ signing_key:
57
63
  specification_version: 4
58
- summary: Reserved for the future
64
+ summary: Spyder
59
65
  test_files: []
data/bin/spyder DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env ruby
2
- require "thor"
3
- puts("LOL, not using thor at all")