nuhttp 0.1.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.
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'socket'
5
+
6
+ module NuHttp
7
+ class Server
8
+ # @rbs skip
9
+ include Socket::Constants
10
+
11
+ RFC9110_REASON_PHRASES = {
12
+ 100 => "Continue",
13
+ 101 => "Switching Protocols",
14
+ 200 => "OK",
15
+ 201 => "Created",
16
+ 202 => "Accepted",
17
+ 203 => "Non-Authoritative Information",
18
+ 204 => "No Content",
19
+ 205 => "Reset Content",
20
+ 206 => "Partial Content",
21
+ 300 => "Multiple Choices",
22
+ 301 => "Moved Permanently",
23
+ 302 => "Found",
24
+ 303 => "See Other",
25
+ 304 => "Not Modified",
26
+ 305 => "Use Proxy",
27
+ 307 => "Temporary Redirect",
28
+ 308 => "Permanent Redirect",
29
+ 400 => "Bad Request",
30
+ 401 => "Unauthorized",
31
+ 402 => "Payment Required",
32
+ 403 => "Forbidden",
33
+ 404 => "Not Found",
34
+ 405 => "Method Not Allowed",
35
+ 406 => "Not Acceptable",
36
+ 407 => "Proxy Authentication Required",
37
+ 408 => "Request Timeout",
38
+ 409 => "Conflict",
39
+ 410 => "Gone",
40
+ 411 => "Length Required",
41
+ 412 => "Precondition Failed",
42
+ 413 => "Content Too Large",
43
+ 414 => "URI Too Long",
44
+ 415 => "Unsupported Media Type",
45
+ 416 => "Range Not Satisfiable",
46
+ 417 => "Expectation Failed",
47
+ 421 => "Misdirected Request",
48
+ 422 => "Unprocessable Content",
49
+ 426 => "Upgrade Required",
50
+ 500 => "Internal Server Error",
51
+ 501 => "Not Implemented",
52
+ 502 => "Bad Gateway",
53
+ 503 => "Service Unavailable",
54
+ 504 => "Gateway Timeout",
55
+ 505 => "HTTP Version Not Supported",
56
+ }.freeze
57
+
58
+ def initialize(app, bind: "127.0.0.1", port: 8080, ractor_mode: false, call_make_shareable: false)
59
+ @app = app
60
+ @bind_address = bind
61
+ @port = port
62
+ @ractor_mode = ractor_mode
63
+
64
+ # When app_make_shareable: true, app will be deep frozen in attempt to
65
+ # make it Ractor shareable.
66
+ # Note: the app may not be made shareable, even if this option is specified.
67
+ Ractor.make_shareable(@app) if call_make_shareable
68
+ raise "app is not shareable" if @ractor_mode && !Ractor.shareable?(@app)
69
+ end
70
+
71
+ def start
72
+ socket = Socket.new(AF_INET, SOCK_STREAM, 0)
73
+ socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, true)
74
+ sockaddr = Socket.pack_sockaddr_in(@port, @bind_address)
75
+ socket.bind(sockaddr)
76
+ socket.listen(64) # backlog
77
+ puts "Listening on #{@bind_address}:#{@port}"
78
+
79
+ loop do
80
+ connn, _ = socket.accept # chose awkward name to avoid shadowing
81
+
82
+ if @ractor_mode
83
+ # Make sure @app does not get copied on Ractor.new
84
+ Ractor.new(@app) do |app|
85
+ conn = Ractor.receive
86
+ Server.handle(app, conn)
87
+ nil # reduce implicit copy on #value
88
+ ensure
89
+ conn.close
90
+ end.send(connn, move: true)
91
+ else
92
+ Server.handle(@app, connn)
93
+ connn.close
94
+ end
95
+ end
96
+ ensure
97
+ socket.close if defined?(socket)
98
+ end
99
+
100
+ def self.handle(app, conn)
101
+ request = HttpParser.parse_request(conn)
102
+
103
+ begin
104
+ res = app.dispatch(request)
105
+ rescue => e
106
+ error_message = [
107
+ "#{e.class}: #{e.message}",
108
+ e.backtrace&.map { |line| "\tfrom #{line}" }&.join("\n")
109
+ ].join("\n")
110
+ puts error_message
111
+
112
+ res = Response.new.tap do |r|
113
+ r.status = 500
114
+ r.headers["Content-Type"] = "text/plain"
115
+ r.body = "Internal Server Error\n"
116
+ end
117
+ end
118
+
119
+ reason_phrase = RFC9110_REASON_PHRASES[res.status] || "Unknown"
120
+
121
+ conn.write "HTTP/1.1 #{res.status} #{reason_phrase}\r\n"
122
+
123
+ # Send headers
124
+ res.headers.each do |header_name, header_body|
125
+ conn.write "#{header_name}: #{header_body}\r\n"
126
+ end
127
+
128
+ chunked = res.headers["Transfer-Encoding"] == "chunked" # ?
129
+
130
+ # Send body
131
+ body = res.body
132
+ if !chunked
133
+ body_str = String.new
134
+ body_str << body
135
+
136
+ conn.write "Content-Length: #{body_str.bytesize}\r\n"
137
+ conn.write "Connection: Close\r\n" # no keepalive impl (yet)
138
+ conn.write "\r\n"
139
+
140
+ conn.write body_str
141
+ else
142
+ conn.write "\r\n"
143
+
144
+ body.each do |chunk|
145
+ conn.write chunk.bytesize.to_s(16) + "\r\n"
146
+ conn.write chunk
147
+ conn.write "\r\n"
148
+ end
149
+
150
+ conn.write "0" + "\r\n\r\n"
151
+ end
152
+ end
153
+
154
+ module HttpParser
155
+ class << self
156
+ def parse_request(io)
157
+ request_line = io.gets("\r\n").chomp
158
+ method, request_target, _http_version = request_line.split(' ', 3)
159
+
160
+ headers = {}
161
+ while line = io.gets("\r\n").chomp
162
+ break if line.empty?
163
+
164
+ header_name, header_value = line.split(':', 2)
165
+ headers[header_name] = header_value.strip
166
+ end
167
+
168
+ path, query = request_target.split('?')
169
+ query ||= ''
170
+
171
+ body =
172
+ if length = headers['Content-Length'] # TODO: match any case
173
+ io.read(length.to_i)
174
+ else
175
+ nil
176
+ end
177
+
178
+
179
+ Request.new(method:, path:, query:, headers:, body:)
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module NuHttp
5
+ VERSION = "0.1.0"
6
+ end
data/lib/nuhttp.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative 'nuhttp/app'
5
+ require_relative 'nuhttp/builder'
6
+ require_relative 'nuhttp/context'
7
+ require_relative 'nuhttp/router'
8
+ require_relative 'nuhttp/version'
9
+
10
+ module NuHttp
11
+ class Error < StandardError; end
12
+
13
+ # Entrypoint to define a NuHttp app.
14
+ # If a block is given, a NuHttp::Builder is yielded.
15
+ # If not, a bare builder will be returned.
16
+ # @rbs &block: (Builder) -> void
17
+ def self.app(&block)
18
+ builder = Builder.new
19
+ return builder if block == nil
20
+ block.call(builder)
21
+ builder.build
22
+ end
23
+
24
+ # Entrypoint to define a Ractor-compatible NuHttp app.
25
+ # Note that many features will be restricted in Ractor mode.
26
+ # See Ruby documentation for details on Ractor limitations.
27
+ # @rbs &block: (Builder) -> void
28
+ def self.ractor_app(&block)
29
+ # Ractor-compatible apps must be frozen at the time of creation
30
+ raise ArgumentError if block == nil
31
+
32
+ builder = Builder.new(ractor_compat: true)
33
+ block.call(builder)
34
+ builder.build
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ # Generated from lib/nuhttp/app.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class App
5
+ def initialize: (untyped router) -> untyped
6
+
7
+ # The entrypoint of the request.
8
+ # @rbs (NuHttp::Context[untyped]) -> void
9
+ def dispatch: (NuHttp::Context[untyped]) -> void
10
+
11
+ def routes: () -> untyped
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # Generated from lib/nuhttp/builder.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class Builder
5
+ def initialize: (?ractor_compat: untyped) -> untyped
6
+
7
+ def build: () -> untyped
8
+
9
+ # : (String, NuHttp::App) -> void
10
+ def mount: (String, NuHttp::App) -> void
11
+ end
12
+ end
@@ -0,0 +1,65 @@
1
+ # Generated from lib/nuhttp/context.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ # @rbs generic ParamsT
5
+ class Context[ParamsT]
6
+ def initialize: (req: untyped, route: untyped) -> untyped
7
+
8
+ def req: () -> Request[ParamsT]
9
+
10
+ def res: () -> Response
11
+
12
+ def status: (untyped code) -> untyped
13
+
14
+ def header: (untyped name, untyped value) -> untyped
15
+
16
+ # TODO: take IO and allow streaming
17
+ def body: (untyped str) -> untyped
18
+
19
+ # Set response to HTML `str`.
20
+ # Content-Type header will be set to `text/html; charset=utf-8`.
21
+ # : (String) -> void
22
+ def html: (String) -> void
23
+
24
+ # Render a template using the provided renderer and set as HTML response.
25
+ # : (String, ?Hash[Symbol, untyped]) -> void
26
+ def erb: (String, ?Hash[Symbol, untyped]) -> void
27
+
28
+ # Set response to plain text `str`.
29
+ # Content-Type header will be set to `text/plain; charset=utf-8`.
30
+ # : (String) -> void
31
+ def text: (String) -> void
32
+
33
+ # Set response to the JSON representation of `obj`.
34
+ # Content-Type header will be set to `application/json`.
35
+ # : (Object) -> void
36
+ def json: (Object) -> void
37
+ end
38
+
39
+ # @rbs generic ParamsT
40
+ class Request[ParamsT]
41
+ attr_reader method: untyped
42
+
43
+ attr_reader path: untyped
44
+
45
+ attr_reader query: untyped
46
+
47
+ attr_reader headers: untyped
48
+
49
+ attr_reader body: untyped
50
+
51
+ attr_accessor params: ParamsT
52
+
53
+ def initialize: (method: untyped, path: untyped, query: untyped, body: untyped, ?headers: untyped) -> untyped
54
+ end
55
+
56
+ class Response
57
+ attr_accessor status: untyped
58
+
59
+ attr_accessor headers: untyped
60
+
61
+ attr_accessor body: untyped
62
+
63
+ def initialize: () -> untyped
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # Generated from lib/nuhttp/herb_renderer.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class HerbRenderer
5
+ def render: (untyped template_path, ?untyped locals) -> untyped
6
+ end
7
+
8
+ class EmptyBinding
9
+ def self.get_binding: () -> Binding
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # Generated from lib/nuhttp/rack_port.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class RackPort
5
+ def initialize: (untyped app) -> untyped
6
+
7
+ def call: (untyped env) -> untyped
8
+
9
+ private def env_to_request: (untyped env) -> untyped
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ # Generated from lib/nuhttp/router.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class Router
5
+ class Route < Data
6
+ attr_reader method(): untyped
7
+
8
+ attr_reader pattern(): untyped
9
+
10
+ attr_reader handler(): untyped
11
+
12
+ def self.new: (untyped method, untyped pattern, untyped handler) -> instance
13
+ | (method: untyped, pattern: untyped, handler: untyped) -> instance
14
+
15
+ def self.members: () -> [ :method, :pattern, :handler ]
16
+
17
+ def members: () -> [ :method, :pattern, :handler ]
18
+ end
19
+
20
+ NOT_FOUND_ROUTE: untyped
21
+
22
+ def initialize: () -> untyped
23
+
24
+ def routes: () -> untyped
25
+
26
+ # @rbs (Symbol, String) -> void
27
+ def register_route: (Symbol, String) -> void
28
+
29
+ # @rbs (NuHttp::Request[untyped]) -> [Route, Hash[untyped, untyped]]
30
+ def resolve: (NuHttp::Request[untyped]) -> [ Route, Hash[untyped, untyped] ]
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ # Generated from lib/nuhttp/server.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class Server
5
+ RFC9110_REASON_PHRASES: untyped
6
+
7
+ def initialize: (untyped app, ?bind: untyped, ?port: untyped, ?ractor_mode: untyped, ?call_make_shareable: untyped) -> untyped
8
+
9
+ def start: () -> untyped
10
+
11
+ def self.handle: (untyped app, untyped conn) -> untyped
12
+
13
+ module HttpParser
14
+ def self.parse_request: (untyped io) -> untyped
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # Generated from lib/nuhttp/version.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ VERSION: ::String
5
+ end
@@ -0,0 +1,18 @@
1
+ # Generated from lib/nuhttp.rb with RBS::Inline
2
+
3
+ module NuHttp
4
+ class Error < StandardError
5
+ end
6
+
7
+ # Entrypoint to define a NuHttp app.
8
+ # If a block is given, a NuHttp::Builder is yielded.
9
+ # If not, a bare builder will be returned.
10
+ # @rbs &block: (Builder) -> void
11
+ def self.app: () { (Builder) -> void } -> untyped
12
+
13
+ # Entrypoint to define a Ractor-compatible NuHttp app.
14
+ # Note that many features will be restricted in Ractor mode.
15
+ # See Ruby documentation for details on Ractor limitations.
16
+ # @rbs &block: (Builder) -> void
17
+ def self.ractor_app: () { (Builder) -> void } -> untyped
18
+ end
data/sig/nuhttp.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Nuhttp
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
data/test/app_test.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'nuhttp'
5
+
6
+ class AppTest < Minitest::Test
7
+ def test_basic_test
8
+ app = NuHttp.app do |b|
9
+ b.get '/' do |c|
10
+ c.res.body = "Hello, World!\n"
11
+ end
12
+ end
13
+
14
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
15
+ res = app.dispatch(req)
16
+ assert_equal "Hello, World!\n", res.body
17
+ end
18
+
19
+ def test_subapp_mounting
20
+ subapp = NuHttp.app do |b|
21
+ b.get '/' do |c|
22
+ c.text "subapp root"
23
+ end
24
+
25
+ b.get '/details' do |c|
26
+ c.text "subapp details"
27
+ end
28
+ end
29
+
30
+ app = NuHttp.app do |b|
31
+ b.mount '/subapp', subapp
32
+ end
33
+
34
+ req = NuHttp::Request.new(method: :get, path: '/subapp/', query: '', body: nil)
35
+ res = app.dispatch(req)
36
+ assert_equal "subapp root", res.body
37
+
38
+ req = NuHttp::Request.new(method: :get, path: '/subapp/details', query: '', body: nil)
39
+ res = app.dispatch(req)
40
+ assert_equal "subapp details", res.body
41
+ end
42
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'nuhttp'
5
+
6
+ class ContextResponseTest < Minitest::Test
7
+ def test_status
8
+ app = NuHttp.app do |b|
9
+ b.get '/' do |c|
10
+ c.status(201)
11
+ c.text("Created")
12
+ end
13
+ end
14
+
15
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
16
+ res = app.dispatch(req)
17
+ assert_equal 201, res.status
18
+ assert_equal "Created", res.body
19
+ end
20
+
21
+ def test_header
22
+ app = NuHttp.app do |b|
23
+ b.get '/' do |c|
24
+ c.status(302)
25
+ c.header('Location', 'https://example.com')
26
+ c.text("Redirecting")
27
+ end
28
+ end
29
+
30
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
31
+ res = app.dispatch(req)
32
+ assert_equal 302, res.status
33
+ assert_equal 'https://example.com', res.headers['Location']
34
+ end
35
+
36
+ def test_html_helper
37
+ app = NuHttp.app do |b|
38
+ b.get '/' do |c|
39
+ c.html("<!DOCTYPE html><html><body><h1>hello, world</h1></body></html>")
40
+ end
41
+ end
42
+
43
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
44
+ res = app.dispatch(req)
45
+ assert_equal "text/html; charset=utf-8", res.headers['Content-Type']
46
+ assert_equal "<!DOCTYPE html><html><body><h1>hello, world</h1></body></html>", res.body
47
+ end
48
+
49
+ def test_text_helper
50
+ app = NuHttp.app do |b|
51
+ b.get '/' do |c|
52
+ c.text("hello, world")
53
+ end
54
+ end
55
+
56
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
57
+ res = app.dispatch(req)
58
+ assert_equal "text/plain; charset=utf-8", res.headers['Content-Type']
59
+ assert_equal "hello, world", res.body
60
+ end
61
+
62
+ def test_json_helper
63
+ app = NuHttp.app do |b|
64
+ b.get '/' do |c|
65
+ c.json({hello: "world"})
66
+ end
67
+ end
68
+
69
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
70
+ res = app.dispatch(req)
71
+ assert_equal "application/json", res.headers['Content-Type']
72
+ assert_equal '{"hello":"world"}', res.body
73
+ end
74
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'nuhttp'
5
+ require 'nuhttp/herb_renderer'
6
+
7
+ class HerbRendererTest < Minitest::Test
8
+ def setup
9
+ @htmlerb_file = Tempfile.open("greeting.html.erb")
10
+ @htmlerb_file.tap {|f| f.write("<p>hello, <%= name %></p>\n"); f.flush }
11
+ end
12
+
13
+ def teardown
14
+ @htmlerb_file.close!
15
+ end
16
+
17
+ def test_renders_template
18
+ html = NuHttp::HerbRenderer.new.render(@htmlerb_file.path, name: "world")
19
+ assert_equal "<p>hello, world</p>\n", html
20
+ end
21
+
22
+ def test_context_render_sets_html_response
23
+ app = NuHttp.app do |b|
24
+ b.get '/' do |c|
25
+ c.erb(@htmlerb_file.path, { name: "world" })
26
+ end
27
+ end
28
+
29
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
30
+ res = app.dispatch(req)
31
+
32
+ assert_equal "text/html; charset=utf-8", res.headers['Content-Type']
33
+ assert_equal "<p>hello, world</p>\n", res.body
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'nuhttp'
5
+ require 'nuhttp/rack_port'
6
+
7
+ class RackPortTest < Minitest::Test
8
+ def test_env_to_request_bare_request
9
+ env = {
10
+ 'REQUEST_METHOD' => 'GET',
11
+ 'PATH_INFO' => '/',
12
+ 'QUERY_STRING' => ''
13
+ }
14
+ req = NuHttp::RackPort.new(env).send(:env_to_request, env)
15
+
16
+ assert_equal 'GET', req.method
17
+ assert_equal '/', req.path
18
+ assert_equal '', req.query
19
+ assert_equal({}, req.headers)
20
+ end
21
+
22
+ def test_env_to_request_with_headers
23
+ env = {
24
+ 'REQUEST_METHOD' => 'GET',
25
+ 'PATH_INFO' => '/',
26
+ 'QUERY_STRING' => '',
27
+ 'CONTENT_LENGTH' => '42',
28
+ 'CONTENT_TYPE' => 'text/plain',
29
+ 'HTTP_HOST' => 'example.com',
30
+ 'HTTP_X_CUSTOM_HEADER' => 'CustomValue'
31
+ }
32
+ req = NuHttp::RackPort.new(env).send(:env_to_request, env)
33
+
34
+ assert_equal 'GET', req.method
35
+ assert_equal '/', req.path
36
+ assert_equal '', req.query
37
+ assert_equal({
38
+ 'Content-Length' => 42,
39
+ 'Content-Type' => 'text/plain',
40
+ 'Host' => 'example.com',
41
+ 'X-Custom-Header' => 'CustomValue'
42
+ }, req.headers)
43
+ end
44
+
45
+ def test_env_to_request_with_body
46
+ env = {
47
+ 'REQUEST_METHOD' => 'GET',
48
+ 'PATH_INFO' => '/',
49
+ 'QUERY_STRING' => ''
50
+ }
51
+ req = NuHttp::RackPort.new(env).send(:env_to_request, env)
52
+
53
+ assert_equal 'GET', req.method
54
+ assert_equal '/', req.path
55
+ assert_equal '', req.query
56
+ assert_equal({}, req.headers)
57
+ end
58
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'nuhttp'
5
+
6
+ class RouterTest < Minitest::Test
7
+ def test_root_path_routing
8
+ router = NuHttp::Router.new
9
+ router.register_route(:get, '/')
10
+
11
+ req = NuHttp::Request.new(method: :get, path: '/', query: '', body: nil)
12
+ resolved = router.resolve(req)[0]
13
+ assert_equal :get, resolved.method
14
+ assert_equal '/', resolved.pattern
15
+ end
16
+
17
+ def test_toplevel_path_routing
18
+ router = NuHttp::Router.new
19
+ router.register_route(:get, '/top')
20
+
21
+ req = NuHttp::Request.new(method: :get, path: '/top', query: '', body: nil)
22
+ resolved = router.resolve(req)[0]
23
+ assert_equal :get, resolved.method
24
+ assert_equal '/top', resolved.pattern
25
+ end
26
+
27
+ def test_notfound_path_routing
28
+ router = NuHttp::Router.new
29
+
30
+ req = NuHttp::Request.new(method: :get, path: '/nonexistent', query: '', body: nil)
31
+ resolved = router.resolve(req)[0]
32
+ assert_equal :internal, resolved.method
33
+ assert_nil resolved.pattern
34
+ end
35
+ end
data/typecheck.png ADDED
Binary file