spyder 0.1.0 → 0.1.1
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/lib/examples/simplest_server/server.rb +17 -0
- data/lib/spyder/error.rb +6 -0
- data/lib/spyder/header_store.rb +55 -0
- data/lib/spyder/request.rb +84 -0
- data/lib/spyder/response.rb +100 -0
- data/lib/spyder/router.rb +42 -0
- data/lib/spyder/server.rb +138 -0
- data/lib/spyder/version.rb +6 -0
- data/lib/spyder.rb +15 -0
- metadata +26 -20
- data/bin/spyder +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d8fbffd16e4740ebf51ca1e51cb49c5a2f665408580340786ac36ba484f045c4
|
4
|
+
data.tar.gz: 6a0c26ce6168805cf1e1636835bd3aa4e376b022be5d4f2874bdfbc80682523e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4cac8464742015ecba92eb7d0ff9ca0d3c7bde3ca14ea274200344de1b1fd92aa424aede419ceab17afe6e60693f54ed6077c27ab233459496e03a780026b9b2
|
7
|
+
data.tar.gz: 37b4244fc29821e8efa337d7ffd0cf6e490bf1e94f526933bfafa1dea08b87c30b5312bfd8c6bf8209794108fab82bb0fc9debf9f2c72cc758cc16d9152104fd
|
@@ -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
|
data/lib/spyder/error.rb
ADDED
@@ -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,138 @@
|
|
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)
|
8
|
+
@server = TCPServer.new(bind, port)
|
9
|
+
@max_threads = max_threads
|
10
|
+
@middleware = []
|
11
|
+
@threads = []
|
12
|
+
@tp_sync = Mutex.new
|
13
|
+
@router = router
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_middleware(callable, args)
|
17
|
+
@middleware << [callable, args]
|
18
|
+
end
|
19
|
+
|
20
|
+
def start
|
21
|
+
@server.listen(10)
|
22
|
+
loop do
|
23
|
+
client = @server.accept
|
24
|
+
|
25
|
+
app_thread = Thread.new do
|
26
|
+
error = nil
|
27
|
+
begin
|
28
|
+
process_new_client(client)
|
29
|
+
rescue Exception => e
|
30
|
+
error = e
|
31
|
+
end
|
32
|
+
|
33
|
+
if error
|
34
|
+
puts error.full_message
|
35
|
+
|
36
|
+
response = Response.make_generic :internal_server_error
|
37
|
+
dispatch_response(client, response)
|
38
|
+
end
|
39
|
+
|
40
|
+
client.close rescue nil
|
41
|
+
end
|
42
|
+
|
43
|
+
over_capacity = true
|
44
|
+
added_thread_to_list = false
|
45
|
+
while over_capacity
|
46
|
+
@tp_sync.synchronize do
|
47
|
+
unless added_thread_to_list
|
48
|
+
@threads << app_thread
|
49
|
+
added_thread_to_list = true
|
50
|
+
end
|
51
|
+
over_capacity = (@threads.length >= @max_threads)
|
52
|
+
# puts("#{@threads.length} of #{@max_threads}")
|
53
|
+
|
54
|
+
@threads.delete_if { |t| !t.alive? }
|
55
|
+
end
|
56
|
+
|
57
|
+
# puts("XXX OVER CAPACITY!") if over_capacity
|
58
|
+
sleep 0 if over_capacity
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def process_request(request)
|
64
|
+
mids = @middleware + [[RouterApp, @router]]
|
65
|
+
app = nil
|
66
|
+
loop do
|
67
|
+
klass, args = mids.pop
|
68
|
+
break unless klass
|
69
|
+
app = klass.new(args, app)
|
70
|
+
end
|
71
|
+
|
72
|
+
app.call({}, request)
|
73
|
+
end
|
74
|
+
|
75
|
+
def process_new_client(socket)
|
76
|
+
verb, path, protocol = read_line(socket).split(' ')
|
77
|
+
request = Request.new
|
78
|
+
request.path = path
|
79
|
+
request.verb = verb
|
80
|
+
request.io = socket
|
81
|
+
|
82
|
+
loop do
|
83
|
+
line = read_line(socket)
|
84
|
+
break if line == ''
|
85
|
+
sep = line.index(':')
|
86
|
+
name = line[0...sep].downcase
|
87
|
+
value = line[(sep + 2)..]
|
88
|
+
request.add_header(name, value)
|
89
|
+
end
|
90
|
+
|
91
|
+
response = process_request(request)
|
92
|
+
|
93
|
+
dispatch_response(socket, response)
|
94
|
+
end
|
95
|
+
|
96
|
+
def dispatch_response(socket, response)
|
97
|
+
content_length = response.headers.dict['content-length']
|
98
|
+
if !content_length && response.body && response.body.is_a?(String)
|
99
|
+
content_length = response.body.length
|
100
|
+
end
|
101
|
+
|
102
|
+
socket.write("HTTP/1.1 #{response.code} #{response.reason_sentence.b}\r\n")
|
103
|
+
response.headers.ordered.each do |name, value|
|
104
|
+
socket.write("#{name.b}: #{value.b}\r\n")
|
105
|
+
end
|
106
|
+
socket.write("connection: close\r\n") # FIXME:
|
107
|
+
socket.write("content-length: #{content_length}\r\n") if content_length
|
108
|
+
socket.write("\r\n")
|
109
|
+
|
110
|
+
if response.body
|
111
|
+
Array(response.body).each do |part|
|
112
|
+
content = part.respond_to?(:call) ? part.call : part
|
113
|
+
socket.write(content.b)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def read_line(socket)
|
119
|
+
line_limit = 1024 * 16
|
120
|
+
buffer = String.new(capacity: 128)
|
121
|
+
almost = false
|
122
|
+
loop do
|
123
|
+
line_limit -= 1
|
124
|
+
return false unless line_limit > 0
|
125
|
+
|
126
|
+
c = socket.readchar
|
127
|
+
if !almost && c == "\r"
|
128
|
+
almost = true
|
129
|
+
elsif almost
|
130
|
+
return false unless c == "\n"
|
131
|
+
return buffer
|
132
|
+
else
|
133
|
+
buffer += c
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
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.
|
4
|
+
version: 0.1.1
|
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:
|
11
|
+
date: 2024-02-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
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
|
-
type: :
|
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
|
27
|
-
description:
|
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
|
-
-
|
35
|
-
|
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
|
-
|
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.
|
56
|
-
signing_key:
|
61
|
+
rubygems_version: 3.5.6
|
62
|
+
signing_key:
|
57
63
|
specification_version: 4
|
58
|
-
summary:
|
64
|
+
summary: Spyder
|
59
65
|
test_files: []
|
data/bin/spyder
DELETED