ratalada 3.0.0 → 4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d0e58c60b99a4b05777a6089c484f2104f85924694cfe0b5c0ef543a03634c7
4
- data.tar.gz: d04a10b62a3f33a00489234bbb0db780517a54c7569c83dfd098cee69a3b2f5f
3
+ metadata.gz: 1da8d4c6eff98bad680c6e3da80c2d452b54711c1d2e2fe5b9de11500640f0a9
4
+ data.tar.gz: 9fae4c8b7aa84cc4ccf69017779f24fb011491a7143c3abb9332483db25489e5
5
5
  SHA512:
6
- metadata.gz: 616cb51549da081369ce6df606e8c9188900b20fc7dc5344b045eaf8ab0618edc8f1cba92fd21be24c59cae0370b2fdfe6729e56e1bf93562b118ebd2f75f4e4
7
- data.tar.gz: f834abb138626a902d8c4947183bca135d0ddf6ab9c8ed92a68b6ba08cc45666edb5f9f55611b50f365e4adbb23c20d621c8fdab2a532a1721c0d0e06e0642e3
6
+ metadata.gz: 6af7b845bd465ede4788967478ad9e67be55e3a2708000f2678a3a4ded46d3b024778d2828bb546e01f9e618fd8a43c1566bf46bdac2b1dff760d65105f3892a
7
+ data.tar.gz: '08bc22a824af6bacdfadc7b6546d0b8c9f116b22c8efc358f4cd7bc97cbe7652a59f23a9beb44616e87b5a3ae8f90a22628d02a1fd6dbf9b7460533336b137c1'
data/README.md CHANGED
@@ -253,6 +253,11 @@ gem kit changelog --write --gem ratalada-roda
253
253
  gem kit release --gem ratalada-roda # gate, build, push, tag
254
254
  ```
255
255
 
256
+ ## Why
257
+
258
+ Spurred on by things like [this post](https://blainsmith.com/articles/just-fucking-use-go/), and just how easy it is to create servers with [hono](https://hono.dev/) or agents with [volt](https://github.com/voltagent/voltagent), which makes me irrationally angry that ruby hasn't more libraries like this considering it's WELL beyond javascript in it's flexibility as a language.
259
+ [This post is great](https://www.toptal.com/developers/ruby/api-with-sinatra-and-sequel-ruby-tutorial), but it shuold be even simpler.
260
+
256
261
  ## License
257
262
 
258
263
  [MIT](LICENSE)
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/http/server"
4
+ require "async/http/endpoint"
5
+ require "protocol/http"
6
+ require "protocol/url"
7
+ require "protocol/rack/adapter"
8
+ require_relative "protocol_http_ext"
9
+ require_relative "../ratalada"
10
+
11
+ module Ratalada
12
+ module Frontends
13
+ # Frontends::Routes, in Protocol::HTTP terms: the same Server.run block,
14
+ # the same handler kinds, no rack in the middle. Requiring ratalada/async
15
+ # installs it in place of the rack router, because Async::HTTP::Server
16
+ # already speaks this protocol and the adapter between them exists only to
17
+ # translate for a frontend that does not.
18
+ #
19
+ # The block is handed the server's own Protocol::HTTP::Request. It sees
20
+ # the same [verb, path] and {verb:, path:, query:} it sees on any other
21
+ # backend (ratalada/protocol_http_ext teaches that class the patterns),
22
+ # and #body is the body as it arrived rather than a String read out of it.
23
+ # What it does not see is Rack::Request: no cookies, no session, no
24
+ # form-body params, no Rack::Utils helpers in scope. Query params are
25
+ # there as #params; the rest is rack's, so require a rack frontend --
26
+ # ratalada/sinatra, or Frontends::Routes back again -- if you want them.
27
+ module ProtocolRoutes
28
+ # Tells the async backend to hand the app to Async::HTTP::Server whole
29
+ # rather than wrapping it in Protocol::Rack::Adapter.
30
+ def self.rack? = false
31
+
32
+ def self.build(block) = App.new(block)
33
+
34
+ # Server.use needs nothing special here: it chains
35
+ # `klass.new(inner, *args)`, which is exactly
36
+ # Protocol::HTTP::Middleware's interface -- `new(delegate)` answering
37
+ # `call(request)`. So middleware on this frontend is Protocol::HTTP
38
+ # middleware, chained with no adapter anywhere. Rack middleware belongs
39
+ # to a rack frontend, where the backend adapts the lot in one go.
40
+
41
+ class App
42
+ def initialize(router)
43
+ @router = router
44
+ end
45
+
46
+ # The request is the server's own Protocol::HTTP::Request, handed to
47
+ # the router as it arrived -- it patterns on [verb, path] and carries
48
+ # #params, #headers and a #body that streams, so there is nothing for
49
+ # a wrapper to add.
50
+ def call(request)
51
+ begin
52
+ handler = @router.call(request)
53
+ rescue NoMatchingPatternError
54
+ handler = nil
55
+ end
56
+ respond(handler, request)
57
+ end
58
+
59
+ # Middleware#close delegates down the chain on shutdown, and this is
60
+ # the bottom of it.
61
+ def close = nil
62
+
63
+ private
64
+
65
+ # Callable is #call, not Proc -- a handler object with a #call is as
66
+ # callable as a lambda. It is tested last for a reason that bites
67
+ # here specifically: Body::Readable#call(stream) is how a body
68
+ # writes itself out, so a Readable answers #call. Tested any earlier
69
+ # and `->(req) { req.body }` would have that body called with the
70
+ # request instead of becoming the response.
71
+ def respond(handler, request)
72
+ case handler
73
+ when nil
74
+ ::Protocol::HTTP::Response[404, { "content-type" => "text/plain" }, ["not found"]]
75
+ when String
76
+ ::Protocol::HTTP::Response[200, { "content-type" => "text/plain" }, [handler]]
77
+ # A body handed back as-is -- most usefully the request's own,
78
+ # which makes an echo or a proxy a pass-through, nothing buffered.
79
+ when ::Protocol::HTTP::Body::Readable
80
+ ::Protocol::HTTP::Response[200, {}, handler]
81
+ when Array
82
+ status, headers, body = handler
83
+ ::Protocol::HTTP::Response[status, headers, body.is_a?(String) ? [body] : body]
84
+ else
85
+ if handler.respond_to?(:call)
86
+ respond(handler.call(request), request)
87
+ else
88
+ handler
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ module Backends
97
+ # Falcon without falcon: one Async::HTTP::Server in this process, no
98
+ # container, no supervision, no restarts. Use ratalada/falcon when you
99
+ # want those; this is the fiber-based server on its own.
100
+ #
101
+ # Named for the server it runs rather than the gem, because `Async` here
102
+ # would shadow the async gem for every sibling in this namespace: an
103
+ # unqualified `Async::Service` inside Backends::Falcon resolved to
104
+ # `Ratalada::Backends::Async::Service` and broke that backend outright.
105
+ module AsyncHTTP
106
+ module_function
107
+
108
+ def run(app, host:, port:, count: 1)
109
+ if count > 1
110
+ warn "ratalada: async backend ignores count: (single process — use ratalada/falcon)"
111
+ end
112
+
113
+ endpoint = ::Async::HTTP::Endpoint.parse("http://#{host}:#{port}")
114
+ server = ::Async::HTTP::Server.new(adapt(app), endpoint)
115
+
116
+ warn "ratalada: async listening on http://#{host}:#{port}"
117
+ # #run wraps itself in Async{}, which outside a reactor starts one and
118
+ # blocks until the accept loop ends.
119
+ server.run
120
+ rescue Interrupt
121
+ # clean shutdown
122
+ end
123
+
124
+ # Async::HTTP::Server speaks Protocol::HTTP natively, so the adapter is
125
+ # only in the way when the frontend does too -- which ProtocolRoutes,
126
+ # installed below, does. Every other frontend (Routes, Builder, Sinatra,
127
+ # Grape, Hanami, Roda) is rack and gets wrapped, without having to
128
+ # declare anything: Ratalada.rack_frontend? assumes rack unless a
129
+ # frontend says `def self.rack? = false`.
130
+ def adapt(app)
131
+ if Ratalada.rack_frontend?
132
+ ::Protocol::Rack::Adapter.new(app)
133
+ else
134
+ app
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ config.backend = Backends::AsyncHTTP
141
+
142
+ # Only the default is replaced. A frontend require sets config.frontend
143
+ # itself, so `require "ratalada/sinatra"` on either side of this one keeps
144
+ # Sinatra (and its rack adapter) rather than racing with it.
145
+ if config.frontend == Frontends::Routes
146
+ config.frontend = Frontends::ProtocolRoutes
147
+ end
148
+ end
@@ -9,6 +9,12 @@ require_relative "../ratalada"
9
9
 
10
10
  module Ratalada
11
11
  module Backends
12
+ # Every Async:: below is rooted at ::. The async backend used to be
13
+ # Backends::Async, and an unqualified lookup found that sibling first:
14
+ # `Async::Service` resolved to `Ratalada::Backends::Async::Service` and
15
+ # took this backend down with it in any process that had required both.
16
+ # It is Backends::AsyncHTTP now, so nothing shadows the gem -- the `::`
17
+ # stays so the next sibling cannot do it again.
12
18
  module Falcon
13
19
  module_function
14
20
 
@@ -26,7 +32,7 @@ module Ratalada
26
32
  def run(app, host:, port:, count: 1, cache: false)
27
33
  middleware = ::Falcon::Server.rack_middleware(app, cache: cache)
28
34
 
29
- environment = Async::Service::Environment.new(::Falcon::Environment::Server).with(
35
+ environment = ::Async::Service::Environment.new(::Falcon::Environment::Server).with(
30
36
  name: "ratalada",
31
37
  url: "http://#{host}:#{port}",
32
38
  middleware: -> { middleware },
@@ -37,7 +43,7 @@ module Ratalada
37
43
  root: Dir.pwd,
38
44
  )
39
45
 
40
- configuration = Async::Service::Configuration.new
46
+ configuration = ::Async::Service::Configuration.new
41
47
  configuration.add(environment)
42
48
 
43
49
  if count > 1
@@ -47,7 +53,10 @@ module Ratalada
47
53
  end
48
54
 
49
55
  warn "ratalada: falcon listening on http://#{host}:#{port}#{workers}"
50
- Async::Service::Controller.run(configuration, container_class: Async::Container.best_container_class)
56
+ ::Async::Service::Controller.run(
57
+ configuration,
58
+ container_class: ::Async::Container.best_container_class,
59
+ )
51
60
  rescue Interrupt
52
61
  # clean shutdown
53
62
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http"
4
+ require "protocol/url"
5
+
6
+ # Pattern matching for Protocol::HTTP::Request, so the router matches the
7
+ # request the server actually built instead of a wrapper standing in for it.
8
+ # `case request in ["GET", "/"]` needs #deconstruct and the class has none --
9
+ # unpatched, every pattern raises NoMatchingPatternError and every route 404s.
10
+ #
11
+ # Additions only: #path still returns the raw target ("/users/1?a=b"), which
12
+ # is what protocol-http1 writes and what anything else reading a request
13
+ # expects. The split path is what the patterns see, because a route is a path
14
+ # and "/hi" should match a request for "/hi?name=x".
15
+ module Protocol
16
+ module HTTP
17
+ class Request
18
+ # The class overrides #method as a reader for the verb, so there is no
19
+ # Object#method here to collide with -- and `in {verb:}` reads better
20
+ # than `in {method:}` would.
21
+ def verb = self.method
22
+
23
+ def reference = @reference ||= ::Protocol::URL::Reference.parse(path)
24
+
25
+ # "" rather than nil with no query string, so `in {query: ""}` means the
26
+ # same here as on the rack router.
27
+ def query = reference.query || ""
28
+
29
+ def params
30
+ if query.empty?
31
+ @params ||= {}
32
+ else
33
+ @params ||= ::Protocol::URL::Encoding.decode(query)
34
+ end
35
+ end
36
+
37
+ # Enables `in ["GET", "/"]`
38
+ def deconstruct = [verb, reference.path.to_s]
39
+
40
+ # Enables `in {verb: "GET", path: "/users"}`
41
+ def deconstruct_keys(_keys) = { verb:, path: reference.path.to_s, query:, params:, headers:, body: }
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ratalada
4
- VERSION = "3.0.0"
4
+ VERSION = "4.0.0"
5
5
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rackup/handler/webrick"
4
+ require_relative "../ratalada"
5
+
6
+ module Ratalada
7
+ module Backends
8
+ # Stdlib WEBrick via rackup's handler, which already does the rack env
9
+ # translation. Single process, one thread per connection — fine for
10
+ # development and tests, not what you ship.
11
+ module Webrick
12
+ module_function
13
+
14
+ def run(app, host:, port:, count: 1)
15
+ if count > 1
16
+ warn "ratalada: webrick backend ignores count: (single process)"
17
+ end
18
+
19
+ warn "ratalada: webrick listening on http://#{host}:#{port}"
20
+ ::Rackup::Handler::WEBrick.run(app, Host: host, Port: port)
21
+ rescue Interrupt
22
+ ::Rackup::Handler::WEBrick.shutdown
23
+ end
24
+ end
25
+ end
26
+
27
+ config.backend = Backends::Webrick
28
+ end
data/lib/ratalada.rb CHANGED
@@ -18,8 +18,10 @@ module Ratalada
18
18
  super(msg || <<~MSG)
19
19
  No server backend selected. Require one before calling Server.run:
20
20
 
21
- require "ratalada/puma" # or
22
- require "ratalada/falcon"
21
+ require "ratalada/puma" # or
22
+ require "ratalada/falcon" # or
23
+ require "ratalada/async" # or
24
+ require "ratalada/webrick"
23
25
  MSG
24
26
  end
25
27
  end
@@ -34,6 +36,15 @@ module Ratalada
34
36
  end
35
37
 
36
38
  def frontend = config.frontend
39
+
40
+ # A frontend is rack unless it says otherwise (`def self.rack? = false`),
41
+ # so every existing one counts without declaring anything. The async
42
+ # backend reads this to decide whether its app needs Protocol::Rack::Adapter.
43
+ def rack_frontend?
44
+ frontend = config.frontend
45
+
46
+ !(frontend.respond_to?(:rack?) && !frontend.rack?)
47
+ end
37
48
  end
38
49
 
39
50
  # Rack::Request plus just enough sugar to pattern match on, so everything
@@ -101,19 +112,25 @@ module Ratalada
101
112
 
102
113
  private
103
114
 
115
+ # Callable is #call, not Proc -- a handler object with a #call is as
116
+ # callable as a lambda, and was being handed back as the response.
117
+ # It is tested last, after every shape that is already a response,
118
+ # because some of those answer #call too for reasons of their own.
104
119
  def respond(handler, request)
105
120
  case handler
106
121
  when nil
107
122
  [404, { "content-type" => "text/plain" }, ["not found"]]
108
- when Proc, Method
109
- respond(handler.call(request), request)
110
123
  when String
111
124
  [200, { "content-type" => "text/plain" }, [handler]]
112
125
  when Array
113
126
  status, headers, body = handler
114
127
  [status, headers, body.is_a?(String) ? [body] : body]
115
128
  else
116
- handler
129
+ if handler.respond_to?(:call)
130
+ respond(handler.call(request), request)
131
+ else
132
+ handler
133
+ end
117
134
  end
118
135
  end
119
136
  end
@@ -177,6 +194,12 @@ module Ratalada
177
194
  end
178
195
 
179
196
  # First `use` in the chain is the outermost, as in Rack::Builder.
197
+ #
198
+ # `klass.new(inner, *args)` is rack's middleware contract and
199
+ # Protocol::HTTP::Middleware's alike -- `new(delegate)`, then `call`
200
+ # with whatever that frontend passes -- so one chain serves both: rack
201
+ # middleware on a rack frontend, Protocol::HTTP middleware on
202
+ # Frontends::ProtocolRoutes.
180
203
  def to_app(block)
181
204
  @middleware.reverse.inject(Ratalada.frontend.build(block)) do |inner, (klass, args, blk)|
182
205
  klass.new(inner, *args, &blk)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ratalada
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan K
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: webrick
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: lefthook
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -135,10 +149,13 @@ files:
135
149
  - README.md
136
150
  - exe/ratalada
137
151
  - lib/ratalada.rb
152
+ - lib/ratalada/async.rb
138
153
  - lib/ratalada/builder.rb
139
154
  - lib/ratalada/falcon.rb
155
+ - lib/ratalada/protocol_http_ext.rb
140
156
  - lib/ratalada/puma.rb
141
157
  - lib/ratalada/version.rb
158
+ - lib/ratalada/webrick.rb
142
159
  homepage: https://github.com/n-at-han-k/ratalada
143
160
  licenses:
144
161
  - MIT