tep 0.11.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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,194 @@
1
+ # Tep::Request -- what the handler reads off the wire.
2
+ module Tep
3
+ class Request
4
+ attr_accessor :verb, :path, :raw_path, :http_version
5
+ attr_accessor :params, :query, :req_headers, :raw_body, :cookies, :session
6
+ attr_accessor :remote_host
7
+ attr_accessor :ivars
8
+ # Set by the auth-filter (Tep::AuthFilter, run before the user's
9
+ # before-filter -- see Tep::App#auth_filter). Always populated:
10
+ # Tep::Identity.anonymous when no provider matched, otherwise
11
+ # the matched provider's Identity. Handlers and filters can
12
+ # rely on req.identity being non-nil.
13
+ attr_accessor :identity
14
+
15
+ def initialize
16
+ @verb = ""
17
+ @path = ""
18
+ @raw_path = ""
19
+ @http_version = "HTTP/1.0"
20
+ @params = Tep.str_hash # path captures + query + form merged
21
+ @query = Tep.str_hash # raw query string only
22
+ @req_headers = Tep.str_hash # downcased header names; renamed
23
+ # from `headers` to avoid sharing
24
+ # an ivar slot with Response (spinel
25
+ # mis-codegens polymorphic ivar
26
+ # writes when two classes share an
27
+ # ivar name).
28
+ @cookies = Tep.str_hash # parsed from Cookie: header
29
+ @session = Session.new # signed cookie store
30
+ @raw_body = "" # same reasoning as req_headers
31
+ @remote_host = ""
32
+ @passed = false # `pass` flag: skip to the next matching route
33
+ @ivars = Tep.str_hash # per-request bag for `@name = ...`
34
+ # set by handlers and `before` filters,
35
+ # read by templates as `ivars[k]`. The
36
+ # Sinatra-compat translator rewrites
37
+ # `@x = v` -> `req.ivars["x"] = (v).to_s`
38
+ # in handler bodies and `@x` -> `ivars["x"]`
39
+ # inside ERB chunks.
40
+ @identity = Tep::Identity.anonymous
41
+ end
42
+
43
+ attr_accessor :passed
44
+ def set_passed; @passed = true; end
45
+
46
+ # Sinatra-compat read aliases. Writers stay on the renamed slots
47
+ # (req_headers, raw_body) -- a `req.headers["X"] = v` from user
48
+ # code goes through these getters, but assignment back into the
49
+ # request via this method name is intentionally not provided
50
+ # (the framework doesn't expect handlers to mutate the request).
51
+ def headers; @req_headers; end
52
+ def body; @raw_body; end
53
+
54
+ # Spinel's Hash[k] returns "" for missing string keys, not nil --
55
+ # so an empty Connection header looks the same as no header at all.
56
+ # We treat both as "use HTTP/1.1 default behaviour".
57
+ def keep_alive?
58
+ lc = @req_headers["connection"].downcase
59
+ if lc == "close"
60
+ return false
61
+ end
62
+ if lc == "keep-alive"
63
+ return true
64
+ end
65
+ @http_version == "HTTP/1.1"
66
+ end
67
+
68
+ def content_length
69
+ @req_headers["content-length"].to_i
70
+ end
71
+
72
+ def form?
73
+ @req_headers["content-type"].downcase.start_with?("application/x-www-form-urlencoded")
74
+ end
75
+
76
+ # True when the request body is a multipart/form-data submission
77
+ # (browsers use this for any form built via `new FormData(...)`
78
+ # or carrying file inputs). Tep::Multipart.parse handles the
79
+ # text fields; file-upload parts are skipped in v1.
80
+ def multipart?
81
+ @req_headers["content-type"].downcase.start_with?("multipart/form-data")
82
+ end
83
+
84
+ # ---- Rack::Request-style accessors (reads only, no .ip yet) ----
85
+ # These are convenience getters over headers we already parse;
86
+ # `.ip` would need a sphttp_accept_with_peer C helper before it
87
+ # can land cleanly, so it's deferred.
88
+
89
+ def host; @req_headers["host"]; end
90
+ def user_agent; @req_headers["user-agent"]; end
91
+ def referer; @req_headers["referer"]; end
92
+ def referrer; @req_headers["referer"]; end # spelling alias
93
+ def accept; @req_headers["accept"]; end
94
+ def content_type; @req_headers["content-type"]; end
95
+
96
+ # tep doesn't terminate TLS itself; both flags reflect "is this
97
+ # connection encrypted from the client's view?" via the
98
+ # `X-Forwarded-Proto: https` header that any reasonable reverse
99
+ # proxy sets.
100
+ def scheme
101
+ proto = @req_headers["x-forwarded-proto"]
102
+ if proto.length > 0
103
+ return proto.downcase
104
+ end
105
+ "http"
106
+ end
107
+
108
+ def ssl?
109
+ scheme == "https"
110
+ end
111
+
112
+ # Pull any remaining body bytes from `client_fd` up to the
113
+ # advertised Content-Length, then merge form / multipart fields
114
+ # into @params. Used by Tep::Server (prefork, blocking fds) --
115
+ # under the prefork model recv() blocks naturally until bytes
116
+ # arrive, so `sphttp_drain_body` (a tight blocking-recv loop)
117
+ # is the right primitive.
118
+ #
119
+ # Tep::Server::Scheduled uses `consume_body_via_scheduler` below
120
+ # instead, because its client fd is non-blocking + a blocking
121
+ # recv would starve the whole worker.
122
+ #
123
+ # No-op on bodyless requests. Form parsing handles
124
+ # `application/x-www-form-urlencoded`; multipart handles
125
+ # `multipart/form-data` (text fields only; file uploads skipped).
126
+ # Other content types leave @raw_body intact for handlers that
127
+ # want to consume it directly.
128
+ def consume_body(client_fd)
129
+ cl = content_length
130
+ already = @raw_body.length
131
+ if cl > already
132
+ rest = Sock.sphttp_drain_body(client_fd, cl - already)
133
+ @raw_body = @raw_body + rest
134
+ end
135
+ parse_form_body
136
+ 0
137
+ end
138
+
139
+ # Scheduler-friendly body drain. Loops on
140
+ # `Sock.sphttp_recv_some` + `Tep::Scheduler.io_wait` so other
141
+ # fibers keep running while we wait for body bytes. Per-recv
142
+ # timeout caps the wait at 5s -- a client that opened the
143
+ # request but never sent the body gets dropped instead of
144
+ # hanging the fiber forever.
145
+ #
146
+ # Returns @raw_body.length after the drain. Body parsing
147
+ # (form / multipart -> @params) happens at the end via
148
+ # parse_form_body, same shape as consume_body.
149
+ def consume_body_via_scheduler(client_fd)
150
+ cl = content_length
151
+ while @raw_body.length < cl
152
+ ready = Tep::Scheduler.io_wait(client_fd, Tep::Scheduler::READ, 5)
153
+ if ready == 0
154
+ break # timeout -- client never finished sending
155
+ end
156
+ chunk = Sock.sphttp_recv_some(client_fd, cl - @raw_body.length)
157
+ if chunk.length == 0
158
+ # Over TLS an empty read can be a partial record (SSL_read
159
+ # WANT_READ/WANT_WRITE) rather than a peer close -- re-park on
160
+ # the indicated direction and retry instead of truncating the
161
+ # body. Plaintext EOF/error (status 3/-1) still breaks.
162
+ st = Sock.sphttp_io_status
163
+ if st == 1
164
+ next
165
+ end
166
+ if st == 2
167
+ Tep::Scheduler.io_wait(client_fd, Tep::Scheduler::WRITE, 5)
168
+ next
169
+ end
170
+ break # peer closed mid-body
171
+ end
172
+ @raw_body = @raw_body + chunk
173
+ end
174
+ parse_form_body
175
+ 0
176
+ end
177
+
178
+ # Shared form / multipart -> @params merge. Both server-side
179
+ # body-drain paths call this once their drain step has filled
180
+ # @raw_body to Content-Length.
181
+ def parse_form_body
182
+ if form?
183
+ Url.parse_query(@raw_body).each do |k, v|
184
+ @params[k] = v
185
+ end
186
+ elsif multipart?
187
+ Tep::Multipart.parse(@raw_body, @req_headers["content-type"]).each do |k, v|
188
+ @params[k] = v
189
+ end
190
+ end
191
+ 0
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,134 @@
1
+ # Tep::Response -- what the handler writes back. Headers are a Bag
2
+ # (string-keyed); the framework adds Content-Length / Connection
3
+ # automatically when serializing.
4
+ module Tep
5
+ class Response
6
+ attr_accessor :status, :headers, :body, :halted, :file_path, :set_cookies
7
+
8
+ def initialize
9
+ @status = 200
10
+ @headers = Tep.str_hash
11
+ @body = ""
12
+ @halted = false
13
+ @file_path = ""
14
+ # `Set-Cookie` is a header that can repeat; can't shove multiple
15
+ # values into a Hash slot. Each entry here is one fully-formatted
16
+ # Set-Cookie line, emitted verbatim by the writer.
17
+ @set_cookies = [""]
18
+ @set_cookies.delete_at(0)
19
+ @streamer = Streamer.new # default no-op; only used when @streaming
20
+ @streaming = false
21
+ # WebSocket upgrade slots. The Tep::Server::Scheduled write
22
+ # path sees @upgrading_ws and, instead of writing the normal
23
+ # status-line response body, emits the 101 handshake response
24
+ # then drives the recv loop via Tep::WebSocket::Connection
25
+ # until the connection closes.
26
+ @upgrading_ws = false
27
+ @ws_accept_key = ""
28
+ @ws_driver = Tep::WebSocket::Driver.new(0)
29
+ # Last-Modified validator as epoch seconds (0 = unset). The header
30
+ # carries the formatted date; this is kept for the conditional-GET
31
+ # comparison against If-Modified-Since (issue #152).
32
+ @lastmod_epoch = 0
33
+ end
34
+
35
+ attr_accessor :streamer, :streaming
36
+ attr_accessor :upgrading_ws, :ws_accept_key, :ws_driver
37
+ attr_reader :lastmod_epoch
38
+
39
+ # ---- HTTP caching helpers (issue #152) ----
40
+
41
+ # Set the Cache-Control header verbatim.
42
+ def cache_control(v)
43
+ @headers["Cache-Control"] = v
44
+ self
45
+ end
46
+
47
+ # Common Cache-Control shortcuts.
48
+ def no_store; cache_control("no-store"); end
49
+ def no_cache; cache_control("no-cache"); end
50
+
51
+ # Cacheable for `secs` seconds: set both Expires (absolute HTTP-date)
52
+ # and Cache-Control: max-age (relative).
53
+ def expires(secs)
54
+ @headers["Expires"] = Sock.sphttp_http_date(Time.now.to_i + secs)
55
+ @headers["Cache-Control"] = "max-age=" + secs.to_s
56
+ self
57
+ end
58
+
59
+ # Strong ETag validator (quoted per RFC 7232).
60
+ def etag(value)
61
+ @headers["ETag"] = "\"" + value + "\""
62
+ self
63
+ end
64
+
65
+ # Last-Modified validator from Unix epoch seconds. Remembers the
66
+ # epoch so conditional GET can compare it to If-Modified-Since.
67
+ def last_modified(epoch)
68
+ @lastmod_epoch = epoch
69
+ @headers["Last-Modified"] = Sock.sphttp_http_date(epoch)
70
+ self
71
+ end
72
+
73
+ def start_stream(streamer)
74
+ @streamer = streamer
75
+ @streaming = true
76
+ end
77
+
78
+ # Mark the response as a WebSocket upgrade. The server writes a
79
+ # 101 Switching Protocols response with the accept-key, assigns
80
+ # the live client fd onto the driver, then runs the recv loop.
81
+ def start_websocket(accept_key, driver)
82
+ @upgrading_ws = true
83
+ @ws_accept_key = accept_key
84
+ @ws_driver = driver
85
+ end
86
+
87
+ # Sinatra-style cookie writer. `opts` is a Bag-of-strings
88
+ # (path, expires, max-age, domain, samesite, httponly, secure).
89
+ # Empty `opts` is fine: just writes "name=value".
90
+ def set_cookie(name, value, opts)
91
+ line = name + "=" + Url.escape(value)
92
+ if opts.length > 0
93
+ opts.each do |k, v|
94
+ if v.length == 0
95
+ line = line + "; " + k # bare flag (HttpOnly, Secure)
96
+ else
97
+ line = line + "; " + k + "=" + v
98
+ end
99
+ end
100
+ end
101
+ @set_cookies.push(line)
102
+ end
103
+
104
+ def send_file(path)
105
+ @file_path = path
106
+ @body = ""
107
+ end
108
+
109
+ # Spinel's polymorphic-receiver write codegen emits a no-op for
110
+ # `res.body = x` when called from a context that has a poly
111
+ # value, so we force the assignment through this method (where
112
+ # `self` is unambiguously Response).
113
+ def set_body_if_empty(s)
114
+ if @body.length == 0 && s.length > 0
115
+ @body = s
116
+ end
117
+ end
118
+
119
+ # Unconditional body setter. Same poly-write rationale as
120
+ # set_body_if_empty (self is unambiguously Response here, so the
121
+ # `@body = s` codegens correctly), but always assigns -- used by
122
+ # Tep::Proxy, which writes the upstream body whether or not it's
123
+ # empty (a 204 / empty upstream body must overwrite, not skip).
124
+ def set_body(s)
125
+ @body = s
126
+ end
127
+
128
+ def set_status(n); @status = n; end
129
+
130
+ def halted_close?
131
+ @halted && @status >= 300
132
+ end
133
+ end
134
+ end
data/lib/tep/router.rb ADDED
@@ -0,0 +1,137 @@
1
+ # Matches incoming requests against a static Route table built up
2
+ # by the Tep.<verb> DSL. Path patterns: literal segments + ":name"
3
+ # captures + "*" splat, OR a regex (via the handler's `is_regex?`).
4
+ #
5
+ # Spinel's type inference unifies parameters across classes that
6
+ # share an ivar name. So Route uses `r_verb` / `r_pat` rather than
7
+ # the more readable `verb` / `pattern` -- otherwise `req.verb` and
8
+ # `route.verb` would make `req` and `route` indistinguishable to
9
+ # the codegen and break ivar writes downstream.
10
+ module Tep
11
+ class Route
12
+ attr_accessor :r_verb, :r_pat, :r_handler, :r_params
13
+
14
+ def initialize(verb, pattern, handler)
15
+ @r_verb = verb
16
+ @r_pat = pattern
17
+ @r_handler = handler
18
+ @r_params = []
19
+ pattern.split("/").each do |part|
20
+ if part.length > 0 && part[0] == ":"
21
+ @r_params.push(part[1, part.length - 1])
22
+ end
23
+ end
24
+ end
25
+
26
+ def handler; @r_handler; end
27
+
28
+ def matches?(req_verb, req_path)
29
+ if req_verb != @r_verb
30
+ return false
31
+ end
32
+ if @r_handler.is_regex?
33
+ return @r_handler.re_match?(req_path)
34
+ end
35
+ pat = @r_pat.split("/")
36
+ req = req_path.split("/")
37
+ if pat.length != req.length
38
+ return false
39
+ end
40
+ i = 0
41
+ while i < pat.length
42
+ pp = pat[i]
43
+ rp = req[i]
44
+ if pp.length > 0 && pp[0] == ":"
45
+ if rp.length == 0
46
+ return false
47
+ end
48
+ elsif pp == "*"
49
+ if rp.length == 0
50
+ return false
51
+ end
52
+ else
53
+ if pp != rp
54
+ return false
55
+ end
56
+ end
57
+ i += 1
58
+ end
59
+ true
60
+ end
61
+
62
+ def fold_captures(req)
63
+ if @r_handler.is_regex?
64
+ caps = @r_handler.re_capture(req.path)
65
+ i = 0
66
+ while i < caps.length
67
+ req.params[(i + 1).to_s] = caps[i]
68
+ i += 1
69
+ end
70
+ return
71
+ end
72
+ pat = @r_pat.split("/")
73
+ rp = req.path.split("/")
74
+ pi = 0
75
+ i = 0
76
+ while i < pat.length
77
+ pp = pat[i]
78
+ if pp.length > 0 && pp[0] == ":"
79
+ name = @r_params[pi]
80
+ req.params[name] = Url.unescape(rp[i])
81
+ pi += 1
82
+ end
83
+ i += 1
84
+ end
85
+ end
86
+ end
87
+
88
+ class Router
89
+ attr_accessor :routes
90
+
91
+ def initialize
92
+ @routes = [Route.new("", "", Handler.new)] # type-seed sentinel
93
+ end
94
+
95
+ def add(verb, pattern, handler)
96
+ @routes.push(Route.new(verb, pattern, handler))
97
+ end
98
+
99
+ def match(req)
100
+ i = 1 # skip the seed at index 0
101
+ while i < @routes.length
102
+ r = @routes[i]
103
+ if r.matches?(req.verb, req.path)
104
+ return r
105
+ end
106
+ i += 1
107
+ end
108
+ nil
109
+ end
110
+
111
+ # Find the next matching route after `start_idx` (1-based; the
112
+ # seed at 0 is skipped). Used by `pass` to step to the next
113
+ # candidate. Returns the Route + its index, or nil + -1.
114
+ def match_after(req, start_idx)
115
+ i = start_idx + 1
116
+ while i < @routes.length
117
+ r = @routes[i]
118
+ if r.matches?(req.verb, req.path)
119
+ return r
120
+ end
121
+ i += 1
122
+ end
123
+ nil
124
+ end
125
+
126
+ def index_of(route)
127
+ i = 0
128
+ while i < @routes.length
129
+ if @routes[i] == route
130
+ return i
131
+ end
132
+ i += 1
133
+ end
134
+ -1
135
+ end
136
+ end
137
+ end