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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +161 -0
- data/Rakefile +8 -0
- data/exe/nuhttp-typegen +116 -0
- data/lib/nuhttp/app.rb +26 -0
- data/lib/nuhttp/builder.rb +57 -0
- data/lib/nuhttp/context.rb +98 -0
- data/lib/nuhttp/herb_renderer.rb +28 -0
- data/lib/nuhttp/rack_port.rb +44 -0
- data/lib/nuhttp/router.rb +56 -0
- data/lib/nuhttp/server.rb +184 -0
- data/lib/nuhttp/version.rb +6 -0
- data/lib/nuhttp.rb +36 -0
- data/sig/generated/nuhttp/app.rbs +13 -0
- data/sig/generated/nuhttp/builder.rbs +12 -0
- data/sig/generated/nuhttp/context.rbs +65 -0
- data/sig/generated/nuhttp/herb_renderer.rbs +11 -0
- data/sig/generated/nuhttp/rack_port.rbs +11 -0
- data/sig/generated/nuhttp/router.rbs +32 -0
- data/sig/generated/nuhttp/server.rbs +17 -0
- data/sig/generated/nuhttp/version.rbs +5 -0
- data/sig/generated/nuhttp.rbs +18 -0
- data/sig/nuhttp.rbs +4 -0
- data/test/app_test.rb +42 -0
- data/test/context_response_test.rb +74 -0
- data/test/herb_renderer_test.rb +35 -0
- data/test/rack_port_test.rb +58 -0
- data/test/router_test.rb +35 -0
- data/typecheck.png +0 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: db78e734229dea342e80d93536d72bd6906242131c5124eaafb9f126e1a00d79
|
|
4
|
+
data.tar.gz: 04b6a209dbb3e16160ee969794b88a7c73f4b4eecd8d092001aad1f36fbc9d0d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 973b701098bff442003f20c842cd290178fc4a064dff3f726bcb04d89e2594f7bb9ddc4baea0f5fdacf1070fe98110281c35ed4fc774577ca17f41421183e34f
|
|
7
|
+
data.tar.gz: df143a6aa9ec6c6c895c856498e810435b66fd86a3c5beeaacbaf09277a9afda0eacd143be275752bcf21116d3352f418663adcfac087bfbc13e96ffec12a364
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Daisuke Aritomo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# NuHttp
|
|
2
|
+
|
|
3
|
+
A new (_nu_) and compact HTTP server library for Ruby.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Compact API.** Good for embedding in tools.
|
|
8
|
+
- **Some typing support.** NuHttp API is (or should be) mostly typed. `params` also recieve types based on path patterns.
|
|
9
|
+
- **Ractor mode.** Explore true concurrency!
|
|
10
|
+
|
|
11
|
+
To be implemented: Performance, profiling, benchmarking
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
NuHttp apps are defined in `NuHttp.app` blocks.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require 'nuhttp'
|
|
19
|
+
require 'nuhttp/server'
|
|
20
|
+
|
|
21
|
+
world = "World"
|
|
22
|
+
|
|
23
|
+
App = NuHttp.app do |b|
|
|
24
|
+
b.get '/' do |c|
|
|
25
|
+
c.res.body = "Hello, #{world}!\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
b.get '/json' do |c|
|
|
29
|
+
c.json({ message: "Hello, #{world}!" })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
b.get '/users/:id' do |c|
|
|
33
|
+
user_id = c.req.params[:id]
|
|
34
|
+
c.json({ user_id: user_id })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
b.get '/html' do |c|
|
|
38
|
+
c.html("<!DOCTYPE html><html> ... </html>")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
b.get '/erb' do |c|
|
|
42
|
+
# HTML+ERB support by Herb (https://github.com/marcoroth/herb)
|
|
43
|
+
# `herb` gem must be installed to use c.erb
|
|
44
|
+
c.erb("/path/to/view.html.erb", { my_variable: 1 })
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
NuHttp::Server.new(App).start
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### As an Rack app
|
|
52
|
+
|
|
53
|
+
While NuHttp ships its own HTTP server, NuHttp apps may be mounted on other servers that implement the Rack spec. Common choices are [Puma](https://github.com/puma/puma), [Unicorn](https://yhbt.net/unicorn/), and [Falcon](https://github.com/socketry/falcon).
|
|
54
|
+
|
|
55
|
+
Define a `config.ru` file:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# config.ru
|
|
59
|
+
|
|
60
|
+
App = NuHttp.app do |b|
|
|
61
|
+
b.get '/' do |c|
|
|
62
|
+
# ...
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
run NuHttp::RackAdapter.new(App)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
... then run the server of your choice.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
$ puma
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Typing support on `c.req.params`
|
|
76
|
+
|
|
77
|
+
[Steep](https://github.com/soutaro/steep) users may benefit from generated type signatures for `c.req.params`.
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
Run `nuhttp-typegen` to scan `*.rb` files and generate types.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
nuhttp-typegen > sig/app.rbs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Ractor Mode! (Ruby 4.0+)
|
|
88
|
+
|
|
89
|
+
Accept the challenge of making your app _Ractor shareable_! The HTTP server shipped with NuHttp spawns a new Ractor for each request, allowing requests to be served in a truly parallel manner.
|
|
90
|
+
|
|
91
|
+
Define your app using`NuHttp.ractor_app` and start the server with `NuHttp::Server.new(app, ractor_mode: true).start`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
require 'nuhttp'
|
|
95
|
+
require 'nuhttp/server'
|
|
96
|
+
|
|
97
|
+
App = NuHttp.ractor_app do |b|
|
|
98
|
+
# Think of each handlerbeing executed in Ractor.new { }
|
|
99
|
+
b.get '/' do |c|
|
|
100
|
+
# ...
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
NuHttp::Server.new(App, ractor_mode: true).start
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Ractor mode brings many restrictions on what can be done in handlers. While you don't have to directly call `Ractor.new` in your code, you should read [ractor.md](https://docs.ruby-lang.org/en/master/language/ractor_md.html) in the Ruby docs for a good understanding of Ractors.
|
|
108
|
+
|
|
109
|
+
Ractor mode requires `Ractor.shareable_proc`, a Ruby 4.0+ API ([Feature #21157](https://bugs.ruby-lang.org/issues/21557)).
|
|
110
|
+
|
|
111
|
+
## FAQ
|
|
112
|
+
|
|
113
|
+
### How is this different from Sinatra?
|
|
114
|
+
|
|
115
|
+
One key difference is the context where the handler is evaluated.
|
|
116
|
+
|
|
117
|
+
In Sinatra, this code raises:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
x = "world"
|
|
121
|
+
class App < Sinatra::Base
|
|
122
|
+
get '/' do
|
|
123
|
+
"hello, #{x}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
NameError: undefined local variable or method 'x' for an instance of App (NameError)
|
|
130
|
+
|
|
131
|
+
"hello, #{x}"
|
|
132
|
+
^
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Installation
|
|
136
|
+
|
|
137
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
bundle add nuhttp
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
gem install nuhttp
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
152
|
+
|
|
153
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
154
|
+
|
|
155
|
+
## Contributing
|
|
156
|
+
|
|
157
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/osyoyu/nuhttp.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/exe/nuhttp-typegen
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'prism'
|
|
6
|
+
|
|
7
|
+
class RouteCollector < Prism::Visitor
|
|
8
|
+
attr_reader :routes
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super()
|
|
12
|
+
@routes = {
|
|
13
|
+
get: [],
|
|
14
|
+
post: [],
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_call_node(node)
|
|
19
|
+
if node.name == :app
|
|
20
|
+
block = node.block
|
|
21
|
+
self.visit(block) if block
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if [:get, :post].include?(node.name)
|
|
25
|
+
http_method = node.name
|
|
26
|
+
path = node.arguments.arguments.first
|
|
27
|
+
return nil if !path.is_a?(Prism::StringNode)
|
|
28
|
+
|
|
29
|
+
path_pattern = path.unescaped
|
|
30
|
+
# Check for dups
|
|
31
|
+
return if @routes[http_method].any? {|r| r[:path_pattern] == path_pattern }
|
|
32
|
+
|
|
33
|
+
param_names = extract_param_names(path.unescaped)
|
|
34
|
+
rbs_interface_name = if param_names.size == 0
|
|
35
|
+
"params_none"
|
|
36
|
+
else
|
|
37
|
+
"params_" + path_pattern.gsub(/[:\/]/, '_').gsub(/[^a-zA-Z0-9_]/, '')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@routes[http_method] << {
|
|
41
|
+
path_pattern:, param_names:, rbs_interface_name:,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_param_names(path_pattern)
|
|
49
|
+
param_names = []
|
|
50
|
+
path_pattern.scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/) do |match|
|
|
51
|
+
param_names << match.first
|
|
52
|
+
end
|
|
53
|
+
param_names
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class RbsEmitter
|
|
58
|
+
def initialize(routes, io = $stdout)
|
|
59
|
+
@routes = routes
|
|
60
|
+
@io = io
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emit
|
|
64
|
+
@io.puts <<-__EOS__
|
|
65
|
+
# Type signature generated by nuhttp-typegen. DO NOT EDIT.
|
|
66
|
+
|
|
67
|
+
module NuHttp
|
|
68
|
+
class Builder
|
|
69
|
+
type params_none = Hash[Symbol, String]
|
|
70
|
+
__EOS__
|
|
71
|
+
|
|
72
|
+
# Interfaces for params
|
|
73
|
+
(@routes[:get] + @routes[:post]).each do |route|
|
|
74
|
+
next if route[:param_names].empty?
|
|
75
|
+
@io.puts <<-__EOS__
|
|
76
|
+
type #{route[:rbs_interface_name]} = Hash[(#{route[:param_names].map {|n| ":#{n}" }.join(" | ")}), String]
|
|
77
|
+
__EOS__
|
|
78
|
+
end
|
|
79
|
+
@io.puts ""
|
|
80
|
+
|
|
81
|
+
# GET
|
|
82
|
+
types = @routes[:get].map do |route|
|
|
83
|
+
"(\"#{route[:path_pattern]}\") { (Context[#{route[:rbs_interface_name]}]) -> void } -> untyped"
|
|
84
|
+
end
|
|
85
|
+
types << "(untyped path) { (Context[params_none]) -> void } -> untyped"
|
|
86
|
+
@io.print <<-__EOS__
|
|
87
|
+
def get: #{types.join("\n | ")}
|
|
88
|
+
__EOS__
|
|
89
|
+
@io.puts ""
|
|
90
|
+
|
|
91
|
+
# POST
|
|
92
|
+
types = @routes[:post].map do |route|
|
|
93
|
+
"(\"#{route[:path_pattern]}\") { (Context[#{route[:rbs_interface_name]}]) -> void } -> untyped"
|
|
94
|
+
end
|
|
95
|
+
types << "(untyped path) { (Context[params_none]) -> void } -> untyped"
|
|
96
|
+
@io.print <<-__EOS__
|
|
97
|
+
def post: #{types.join("\n | ")}
|
|
98
|
+
__EOS__
|
|
99
|
+
@io.puts ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@io.print <<-__EOS__
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
__EOS__
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
paths = Dir.glob("**/*.rb")
|
|
110
|
+
visitor = RouteCollector.new
|
|
111
|
+
paths.each do |path|
|
|
112
|
+
parsed = Prism.parse(File.read(path))
|
|
113
|
+
|
|
114
|
+
visitor.visit(parsed.value)
|
|
115
|
+
end
|
|
116
|
+
RbsEmitter.new(visitor.routes).emit
|
data/lib/nuhttp/app.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module NuHttp
|
|
5
|
+
class App
|
|
6
|
+
def initialize(router)
|
|
7
|
+
@router = router
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# The entrypoint of the request.
|
|
11
|
+
# @rbs (NuHttp::Context[untyped]) -> void
|
|
12
|
+
def dispatch(req)
|
|
13
|
+
route, params = @router.resolve(req)
|
|
14
|
+
# Create a new Context
|
|
15
|
+
ctx = Context.new(req:, route:)
|
|
16
|
+
# Populate req.params
|
|
17
|
+
req.params = params
|
|
18
|
+
route.handler.call(ctx)
|
|
19
|
+
ctx.res
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def routes
|
|
23
|
+
@router.routes
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module NuHttp
|
|
5
|
+
class Builder
|
|
6
|
+
def initialize(ractor_compat: false)
|
|
7
|
+
@router = Router.new
|
|
8
|
+
@ractor_compat = ractor_compat
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build
|
|
12
|
+
if @ractor_compat
|
|
13
|
+
@router.routes.freeze
|
|
14
|
+
Ractor.make_shareable @router
|
|
15
|
+
raise if !Ractor.shareable?(@router)
|
|
16
|
+
end
|
|
17
|
+
App.new(@router).tap do |app|
|
|
18
|
+
app.freeze if @ractor_compat
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The `nuhttp-typegen` tool can be used to generated RBS type signatures
|
|
23
|
+
# tailored for each app.
|
|
24
|
+
# @rbs skip
|
|
25
|
+
def get(path, &block)
|
|
26
|
+
if @ractor_compat
|
|
27
|
+
shareable_block = Ractor.shareable_proc(&block)
|
|
28
|
+
@router.register_route(:get, path, &shareable_block)
|
|
29
|
+
else
|
|
30
|
+
@router.register_route(:get, path, &block)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The `nuhttp-typegen` tool can be used to generated RBS type signatures
|
|
35
|
+
# tailored for each app.
|
|
36
|
+
# @rbs skip
|
|
37
|
+
def post(path, &block)
|
|
38
|
+
if @ractor_compat
|
|
39
|
+
shareable_block = Ractor.shareable_proc(&block)
|
|
40
|
+
@router.register_route(:post, path, &shareable_block)
|
|
41
|
+
else
|
|
42
|
+
@router.register_route(:post, path, &block)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (String, NuHttp::App) -> void
|
|
47
|
+
def mount(path, subapp)
|
|
48
|
+
subapp.routes.each do |route|
|
|
49
|
+
@router.register_route(
|
|
50
|
+
route.method,
|
|
51
|
+
path + route.pattern,
|
|
52
|
+
&route.handler
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module NuHttp
|
|
5
|
+
# @rbs generic ParamsT
|
|
6
|
+
class Context
|
|
7
|
+
def initialize(req:, route:)
|
|
8
|
+
@req = req
|
|
9
|
+
@res = Response.new
|
|
10
|
+
|
|
11
|
+
@herb_renderer_loaded = false
|
|
12
|
+
@json_loaded = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def req #: Request[ParamsT]
|
|
16
|
+
@req
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def res #: Response
|
|
20
|
+
@res
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def status(code)
|
|
24
|
+
res.status = code
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def header(name, value)
|
|
28
|
+
res.headers[name] = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# TODO: take IO and allow streaming
|
|
32
|
+
def body(str)
|
|
33
|
+
res.body = str
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Set response to HTML `str`.
|
|
37
|
+
# Content-Type header will be set to `text/html; charset=utf-8`.
|
|
38
|
+
#: (String) -> void
|
|
39
|
+
def html(str)
|
|
40
|
+
res.headers['Content-Type'] = 'text/html; charset=utf-8'
|
|
41
|
+
res.body = str
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Render a template using the provided renderer and set as HTML response.
|
|
45
|
+
#: (String, ?Hash[Symbol, untyped]) -> void
|
|
46
|
+
def erb(template_path, locals = {})
|
|
47
|
+
require_relative './herb_renderer' if !@herb_renderer_loaded
|
|
48
|
+
|
|
49
|
+
rendered = HerbRenderer.new.render(template_path, locals)
|
|
50
|
+
html(rendered)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Set response to plain text `str`.
|
|
54
|
+
# Content-Type header will be set to `text/plain; charset=utf-8`.
|
|
55
|
+
#: (String) -> void
|
|
56
|
+
def text(str)
|
|
57
|
+
res.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
|
58
|
+
res.body = str
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Set response to the JSON representation of `obj`.
|
|
62
|
+
# Content-Type header will be set to `application/json`.
|
|
63
|
+
#: (Object) -> void
|
|
64
|
+
def json(obj)
|
|
65
|
+
require 'json' if !@json_loaded
|
|
66
|
+
|
|
67
|
+
res.headers['Content-Type'] = 'application/json'
|
|
68
|
+
res.body = JSON.generate(obj)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @rbs generic ParamsT
|
|
73
|
+
class Request
|
|
74
|
+
attr_reader :method, :path, :query, :headers, :body
|
|
75
|
+
attr_accessor :params #: ParamsT
|
|
76
|
+
|
|
77
|
+
def initialize(method:, path:, query:, headers: {}, body:)
|
|
78
|
+
@method = method
|
|
79
|
+
@path = path
|
|
80
|
+
@query = query
|
|
81
|
+
|
|
82
|
+
@headers = headers
|
|
83
|
+
@body = body
|
|
84
|
+
|
|
85
|
+
@params = {}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Response
|
|
90
|
+
attr_accessor :status, :headers, :body
|
|
91
|
+
|
|
92
|
+
def initialize
|
|
93
|
+
@status = 200
|
|
94
|
+
@headers = {}
|
|
95
|
+
@body = ""
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'herb'
|
|
5
|
+
|
|
6
|
+
module NuHttp
|
|
7
|
+
class HerbRenderer
|
|
8
|
+
def render(template_path, locals = {})
|
|
9
|
+
template_src = File.read(template_path)
|
|
10
|
+
engine = Herb::Engine.new(template_src, filename: template_path)
|
|
11
|
+
|
|
12
|
+
erbctx = EmptyBinding.get_binding
|
|
13
|
+
locals.each do |name, value|
|
|
14
|
+
erbctx.local_variable_set(name, value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Herb::Engine generates Ruby code that returns the rendered string when evaluated
|
|
18
|
+
ruby_src = engine.src
|
|
19
|
+
eval(ruby_src, erbctx)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class EmptyBinding
|
|
24
|
+
def self.get_binding #: Binding
|
|
25
|
+
binding
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'rack'
|
|
5
|
+
|
|
6
|
+
module NuHttp
|
|
7
|
+
class RackPort
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
req = env_to_request(env)
|
|
14
|
+
res = @app.dispatch(req)
|
|
15
|
+
|
|
16
|
+
[res.status, res.headers, [res.body]]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private def env_to_request(env)
|
|
20
|
+
method = env['REQUEST_METHOD']
|
|
21
|
+
path = env['PATH_INFO'] || '/'
|
|
22
|
+
query = env['QUERY_STRING'] || ''
|
|
23
|
+
|
|
24
|
+
# Extract request headers
|
|
25
|
+
# Racks stores headers in the env with 'HTTP_' prefix, with the
|
|
26
|
+
# exception of Content-Type and Content-Length
|
|
27
|
+
headers = {}
|
|
28
|
+
if env['CONTENT_TYPE']
|
|
29
|
+
headers['Content-Type'] = env['CONTENT_TYPE']
|
|
30
|
+
end
|
|
31
|
+
if env['CONTENT_LENGTH']
|
|
32
|
+
headers['Content-Length'] = env['CONTENT_LENGTH'].to_i
|
|
33
|
+
end
|
|
34
|
+
env.each do |key, value|
|
|
35
|
+
header_name = key.start_with?('HTTP_') ? key[5..].split('_').map(&:capitalize).join('-') : nil
|
|
36
|
+
if header_name
|
|
37
|
+
headers[header_name] = value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Request.new(method:, path:, query:, headers:, body: '')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module NuHttp
|
|
5
|
+
class Router
|
|
6
|
+
Route = Data.define(:method, :pattern, :handler)
|
|
7
|
+
|
|
8
|
+
NOT_FOUND_ROUTE = Route.new(
|
|
9
|
+
method: :internal,
|
|
10
|
+
pattern: nil,
|
|
11
|
+
handler: Ractor.shareable_proc do |c|
|
|
12
|
+
c.res.status = 404
|
|
13
|
+
c.res.body = "Not Found\n"
|
|
14
|
+
end
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@routes = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def routes
|
|
22
|
+
@routes
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (Symbol, String) -> void
|
|
26
|
+
def register_route(method, pattern, &block)
|
|
27
|
+
@routes << Route.new(method:, pattern:, handler: block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (NuHttp::Request[untyped]) -> [Route, Hash[untyped, untyped]]
|
|
31
|
+
def resolve(req)
|
|
32
|
+
@routes.each do |route|
|
|
33
|
+
# Turn the user supplied pattern into a regexp that captures named params.
|
|
34
|
+
param_names = []
|
|
35
|
+
regexp = /\A#{route.pattern.gsub(/:[^\/]+/) {|segment|
|
|
36
|
+
param_names << segment.delete_prefix(':')
|
|
37
|
+
"([^/]+)"
|
|
38
|
+
}}\z/
|
|
39
|
+
|
|
40
|
+
# Test if the request path matches the route pattern
|
|
41
|
+
match = regexp.match(req.path)
|
|
42
|
+
next unless match
|
|
43
|
+
|
|
44
|
+
params = {}
|
|
45
|
+
param_names.each.with_index do |name, index|
|
|
46
|
+
params[name.to_sym] = match.captures[index]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return [route, params]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# If no route matches, return a fixed 404 response
|
|
53
|
+
[NOT_FOUND_ROUTE, {}]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|