funapi 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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/25-09-01-OPENAPI_IMPLEMENTATION.md +233 -0
  3. data/.claude/25-09-05-RESPONSE_SCHEMA.md +383 -0
  4. data/.claude/25-09-10-OPENAPI_PLAN.md +219 -0
  5. data/.claude/25-10-26-MIDDLEWARE_IMPLEMENTATION.md +230 -0
  6. data/.claude/25-10-26-MIDDLEWARE_PLAN.md +353 -0
  7. data/.claude/25-10-27-BACKGROUND_TASKS_ANALYSIS.md +325 -0
  8. data/.claude/25-10-27-DEPENDENCY_IMPLEMENTATION_SUMMARY.md +325 -0
  9. data/.claude/25-10-27-DEPENDENCY_INJECTION_PLAN.md +753 -0
  10. data/.claude/25-12-24-LIFECYCLE_HOOKS_PLAN.md +421 -0
  11. data/.claude/25-12-24-PUBLISHING_AND_DOGFOODING_PLAN.md +327 -0
  12. data/.claude/25-12-24-TEMPLATE_RENDERING_PLAN.md +704 -0
  13. data/.claude/DECISIONS.md +397 -0
  14. data/.claude/PROJECT_PLAN.md +80 -0
  15. data/.claude/TESTING_PLAN.md +285 -0
  16. data/.claude/TESTING_STATUS.md +157 -0
  17. data/.tool-versions +1 -0
  18. data/AGENTS.md +416 -0
  19. data/CHANGELOG.md +5 -0
  20. data/CODE_OF_CONDUCT.md +132 -0
  21. data/LICENSE.txt +21 -0
  22. data/README.md +660 -0
  23. data/Rakefile +10 -0
  24. data/docs +8 -0
  25. data/docs-site/.gitignore +3 -0
  26. data/docs-site/Gemfile +9 -0
  27. data/docs-site/app.rb +138 -0
  28. data/docs-site/content/essential/handler.md +156 -0
  29. data/docs-site/content/essential/lifecycle.md +161 -0
  30. data/docs-site/content/essential/middleware.md +201 -0
  31. data/docs-site/content/essential/openapi.md +155 -0
  32. data/docs-site/content/essential/routing.md +123 -0
  33. data/docs-site/content/essential/validation.md +166 -0
  34. data/docs-site/content/getting-started/at-glance.md +82 -0
  35. data/docs-site/content/getting-started/key-concepts.md +150 -0
  36. data/docs-site/content/getting-started/quick-start.md +127 -0
  37. data/docs-site/content/index.md +81 -0
  38. data/docs-site/content/patterns/async-operations.md +137 -0
  39. data/docs-site/content/patterns/background-tasks.md +143 -0
  40. data/docs-site/content/patterns/database.md +175 -0
  41. data/docs-site/content/patterns/dependencies.md +141 -0
  42. data/docs-site/content/patterns/deployment.md +212 -0
  43. data/docs-site/content/patterns/error-handling.md +184 -0
  44. data/docs-site/content/patterns/response-schema.md +159 -0
  45. data/docs-site/content/patterns/templates.md +193 -0
  46. data/docs-site/content/patterns/testing.md +218 -0
  47. data/docs-site/mise.toml +2 -0
  48. data/docs-site/public/css/style.css +234 -0
  49. data/docs-site/templates/layouts/docs.html.erb +28 -0
  50. data/docs-site/templates/page.html.erb +3 -0
  51. data/docs-site/templates/partials/_nav.html.erb +19 -0
  52. data/examples/background_tasks_demo.rb +159 -0
  53. data/examples/demo_middleware.rb +55 -0
  54. data/examples/demo_openapi.rb +63 -0
  55. data/examples/dependency_block_demo.rb +150 -0
  56. data/examples/dependency_cleanup_demo.rb +146 -0
  57. data/examples/dependency_injection_demo.rb +200 -0
  58. data/examples/lifecycle_demo.rb +57 -0
  59. data/examples/middleware_demo.rb +74 -0
  60. data/examples/templates/layouts/application.html.erb +66 -0
  61. data/examples/templates/todos/_todo.html.erb +15 -0
  62. data/examples/templates/todos/index.html.erb +12 -0
  63. data/examples/templates_demo.rb +87 -0
  64. data/lib/funapi/application.rb +521 -0
  65. data/lib/funapi/async.rb +57 -0
  66. data/lib/funapi/background_tasks.rb +52 -0
  67. data/lib/funapi/config.rb +23 -0
  68. data/lib/funapi/database/sequel/fibered_connection_pool.rb +87 -0
  69. data/lib/funapi/dependency_wrapper.rb +66 -0
  70. data/lib/funapi/depends.rb +138 -0
  71. data/lib/funapi/exceptions.rb +72 -0
  72. data/lib/funapi/middleware/base.rb +13 -0
  73. data/lib/funapi/middleware/cors.rb +23 -0
  74. data/lib/funapi/middleware/request_logger.rb +32 -0
  75. data/lib/funapi/middleware/trusted_host.rb +34 -0
  76. data/lib/funapi/middleware.rb +4 -0
  77. data/lib/funapi/openapi/schema_converter.rb +85 -0
  78. data/lib/funapi/openapi/spec_generator.rb +179 -0
  79. data/lib/funapi/router.rb +43 -0
  80. data/lib/funapi/schema.rb +65 -0
  81. data/lib/funapi/server/falcon.rb +38 -0
  82. data/lib/funapi/template_response.rb +17 -0
  83. data/lib/funapi/templates.rb +111 -0
  84. data/lib/funapi/version.rb +5 -0
  85. data/lib/funapi.rb +14 -0
  86. data/sig/fun_api.rbs +499 -0
  87. metadata +220 -0
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/funapi"
4
+ require_relative "../lib/funapi/server/falcon"
5
+
6
+ FAKE_DB = {
7
+ users: [
8
+ {id: 1, name: "Alice", email: "alice@example.com", role: "admin"},
9
+ {id: 2, name: "Bob", email: "bob@example.com", role: "user"},
10
+ {id: 3, name: "Charlie", email: "charlie@example.com", role: "user"}
11
+ ]
12
+ }
13
+
14
+ class Database
15
+ def self.connect
16
+ new
17
+ end
18
+
19
+ def find_user(id)
20
+ FAKE_DB[:users].find { |u| u[:id] == id.to_i }
21
+ end
22
+
23
+ def all_users
24
+ FAKE_DB[:users]
25
+ end
26
+
27
+ def create_user(attrs)
28
+ id = (FAKE_DB[:users].map { |u| u[:id] }.max || 0) + 1
29
+ user = attrs.merge(id: id)
30
+ FAKE_DB[:users] << user
31
+ user
32
+ end
33
+ end
34
+
35
+ def require_auth
36
+ lambda { |req:|
37
+ token = req.env["HTTP_AUTHORIZATION"]&.split(" ")&.last
38
+ raise FunApi::HTTPException.new(status_code: 401, detail: "Not authenticated") unless token
39
+
40
+ user_id = token.to_i
41
+ raise FunApi::HTTPException.new(status_code: 401, detail: "Invalid token") if user_id.zero?
42
+
43
+ user_id
44
+ }
45
+ end
46
+
47
+ def get_current_user
48
+ lambda { |db:, user_id: FunApi::Depends(require_auth)|
49
+ user = db.find_user(user_id)
50
+ raise FunApi::HTTPException.new(status_code: 404, detail: "User not found") unless user
51
+
52
+ user
53
+ }
54
+ end
55
+
56
+ def require_admin
57
+ lambda { |user: FunApi::Depends(get_current_user)|
58
+ raise FunApi::HTTPException.new(status_code: 403, detail: "Admin access required") unless user[:role] == "admin"
59
+
60
+ user
61
+ }
62
+ end
63
+
64
+ class Paginator
65
+ def initialize(max_limit: 100)
66
+ @max_limit = max_limit
67
+ end
68
+
69
+ def call(input:)
70
+ limit = (input[:query][:limit] || 10).to_i
71
+ offset = (input[:query][:offset] || 0).to_i
72
+
73
+ {
74
+ limit: [limit, @max_limit].min,
75
+ offset: [offset, 0].max
76
+ }
77
+ end
78
+ end
79
+
80
+ QuerySchema = FunApi::Schema.define do
81
+ optional(:limit).filled(:integer)
82
+ optional(:offset).filled(:integer)
83
+ end
84
+
85
+ UserCreateSchema = FunApi::Schema.define do
86
+ required(:name).filled(:string)
87
+ required(:email).filled(:string)
88
+ optional(:role).filled(:string)
89
+ end
90
+
91
+ app = FunApi::App.new(
92
+ title: "Dependency Injection Demo",
93
+ version: "1.0.0",
94
+ description: "Demonstrating FunApi dependency injection features"
95
+ ) do |api|
96
+ api.register(:db) { Database.connect }
97
+
98
+ api.register(:logger) do
99
+ logger = Logger.new($stdout)
100
+ logger.level = Logger::INFO
101
+ logger
102
+ end
103
+
104
+ api.get "/" do |_input, _req, _task|
105
+ [{
106
+ message: "Dependency Injection Demo API",
107
+ endpoints: {
108
+ public: "GET /",
109
+ users_list: "GET /users",
110
+ user_detail: "GET /users/:id",
111
+ profile: "GET /profile (requires auth)",
112
+ create_user: "POST /users (requires admin)",
113
+ admin: "GET /admin (requires admin)"
114
+ },
115
+ auth: "Use header: Authorization: Bearer <user_id>"
116
+ }, 200]
117
+ end
118
+
119
+ api.get "/users",
120
+ query: QuerySchema,
121
+ depends: {
122
+ db: nil,
123
+ page: Paginator.new(max_limit: 50)
124
+ } do |_input, _req, _task, db:, page:|
125
+ users = db.all_users[page[:offset], page[:limit]]
126
+
127
+ [{
128
+ users: users,
129
+ pagination: page,
130
+ total: FAKE_DB[:users].length
131
+ }, 200]
132
+ end
133
+
134
+ api.get "/users/:id",
135
+ depends: [:db] do |input, _req, _task, db:|
136
+ user = db.find_user(input[:path]["id"])
137
+ raise FunApi::HTTPException.new(status_code: 404, detail: "User not found") unless user
138
+
139
+ [user, 200]
140
+ end
141
+
142
+ api.get "/profile",
143
+ depends: {
144
+ user: get_current_user,
145
+ db: nil
146
+ } do |_input, _req, _task, user:, db:|
147
+ [user, 200]
148
+ end
149
+
150
+ api.post "/users",
151
+ body: UserCreateSchema,
152
+ depends: {
153
+ admin: require_admin,
154
+ db: nil,
155
+ logger: nil
156
+ } do |input, _req, _task, admin:, db:, logger:|
157
+ user_data = input[:body]
158
+ user_data[:role] ||= "user"
159
+
160
+ new_user = db.create_user(user_data)
161
+ logger.info("Admin #{admin[:name]} created user: #{new_user[:name]}")
162
+
163
+ [new_user, 201]
164
+ end
165
+
166
+ api.get "/admin",
167
+ depends: {admin: require_admin} do |_input, _req, _task, admin:|
168
+ [{
169
+ message: "Welcome to admin area",
170
+ admin: admin
171
+ }, 200]
172
+ end
173
+ end
174
+
175
+ puts "\n🚀 FunApi Dependency Injection Demo"
176
+ puts "=" * 50
177
+ puts "\nServer starting on http://localhost:3000"
178
+ puts "\n📚 API Docs: http://localhost:3000/docs"
179
+ puts "\n✨ Try these commands:\n"
180
+ puts "\n# Public endpoint"
181
+ puts "curl http://localhost:3000/"
182
+ puts "\n# List users (with pagination)"
183
+ puts "curl http://localhost:3000/users"
184
+ puts "curl 'http://localhost:3000/users?limit=2&offset=0'"
185
+ puts "\n# Get specific user"
186
+ puts "curl http://localhost:3000/users/1"
187
+ puts "\n# Get profile (requires auth)"
188
+ puts "curl -H 'Authorization: Bearer 1' http://localhost:3000/profile"
189
+ puts "curl -H 'Authorization: Bearer 2' http://localhost:3000/profile"
190
+ puts "\n# Admin area (requires admin role)"
191
+ puts "curl -H 'Authorization: Bearer 1' http://localhost:3000/admin"
192
+ puts "curl -H 'Authorization: Bearer 2' http://localhost:3000/admin # Should fail (403)"
193
+ puts "\n# Create user (requires admin)"
194
+ puts "curl -X POST http://localhost:3000/users \\"
195
+ puts " -H 'Authorization: Bearer 1' \\"
196
+ puts " -H 'Content-Type: application/json' \\"
197
+ puts " -d '{\"name\":\"Dave\",\"email\":\"dave@example.com\",\"role\":\"user\"}'"
198
+ puts "\n" + ("=" * 50) + "\n\n"
199
+
200
+ FunApi::Server::Falcon.start(app, port: 3000)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/funapi"
4
+ require_relative "../lib/funapi/server/falcon"
5
+
6
+ DB = {connected: false, users: []}
7
+ CACHE = {warmed: false}
8
+
9
+ app = FunApi::App.new(
10
+ title: "Lifecycle Demo",
11
+ version: "1.0.0"
12
+ ) do |api|
13
+ api.on_startup do
14
+ puts "Connecting to database..."
15
+ sleep 0.1
16
+ DB[:connected] = true
17
+ DB[:users] = [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}]
18
+ puts "Database connected!"
19
+ end
20
+
21
+ api.on_startup do
22
+ puts "Warming cache..."
23
+ sleep 0.05
24
+ CACHE[:warmed] = true
25
+ puts "Cache warmed!"
26
+ end
27
+
28
+ api.on_shutdown do
29
+ puts "Closing database connection..."
30
+ DB[:connected] = false
31
+ puts "Database disconnected!"
32
+ end
33
+
34
+ api.on_shutdown do
35
+ puts "Clearing cache..."
36
+ CACHE[:warmed] = false
37
+ puts "Cache cleared!"
38
+ end
39
+
40
+ api.get "/status" do |_input, _req, _task|
41
+ [{
42
+ db_connected: DB[:connected],
43
+ cache_warmed: CACHE[:warmed]
44
+ }, 200]
45
+ end
46
+
47
+ api.get "/users" do |_input, _req, _task|
48
+ [DB[:users], 200]
49
+ end
50
+ end
51
+
52
+ puts "Starting Lifecycle Demo..."
53
+ puts "Try: curl http://localhost:3000/status"
54
+ puts "Try: curl http://localhost:3000/users"
55
+ puts ""
56
+
57
+ FunApi::Server::Falcon.start(app, port: 3000)
@@ -0,0 +1,74 @@
1
+ require_relative "../lib/funapi"
2
+ require_relative "../lib/funapi/server/falcon"
3
+
4
+ UserSchema = FunApi::Schema.define do
5
+ required(:name).filled(:string)
6
+ required(:email).filled(:string)
7
+ end
8
+
9
+ app = FunApi::App.new(
10
+ title: "Middleware Demo API",
11
+ version: "1.0.0",
12
+ description: "Demonstrating FunApi middleware capabilities"
13
+ ) do |api|
14
+ api.add_cors(
15
+ allow_origins: ["http://localhost:3000", "http://127.0.0.1:3000"],
16
+ allow_methods: %w[GET POST PUT DELETE],
17
+ allow_headers: %w[Content-Type Authorization]
18
+ )
19
+
20
+ api.add_request_logger
21
+
22
+ api.add_trusted_host(
23
+ allowed_hosts: ["localhost", "127.0.0.1", /\.example\.com$/]
24
+ )
25
+
26
+ api.get "/" do |_input, _req, _task|
27
+ [{message: "Welcome to FunApi Middleware Demo!"}, 200]
28
+ end
29
+
30
+ api.get "/health" do |_input, _req, _task|
31
+ [{status: "healthy", timestamp: Time.now.to_i}, 200]
32
+ end
33
+
34
+ api.post "/users", body: UserSchema do |input, _req, _task|
35
+ user = input[:body].merge(id: rand(1000), created_at: Time.now.to_i)
36
+ [user, 201]
37
+ end
38
+
39
+ api.get "/async-demo" do |_input, _req, task|
40
+ result1 = task.async do
41
+ sleep 0.1
42
+ {data: "from task 1"}
43
+ end
44
+ result2 = task.async do
45
+ sleep 0.1
46
+ {data: "from task 2"}
47
+ end
48
+
49
+ [{results: [result1.wait, result2.wait]}, 200]
50
+ end
51
+ end
52
+
53
+ puts "🚀 Middleware Demo Server"
54
+ puts "=========================="
55
+ puts ""
56
+ puts "Enabled Middleware:"
57
+ puts " ✓ CORS (localhost:3000, 127.0.0.1:3000)"
58
+ puts " ✓ Request Logger"
59
+ puts " ✓ Trusted Host"
60
+ puts ""
61
+ puts "Endpoints:"
62
+ puts " GET http://localhost:3000/"
63
+ puts " GET http://localhost:3000/health"
64
+ puts " POST http://localhost:3000/users"
65
+ puts " GET http://localhost:3000/async-demo"
66
+ puts " GET http://localhost:3000/docs (Swagger UI)"
67
+ puts ""
68
+ puts "Try:"
69
+ puts " curl http://localhost:3000/"
70
+ puts " curl -X POST http://localhost:3000/users -H 'Content-Type: application/json' -d '{\"name\":\"John\",\"email\":\"john@example.com\"}'"
71
+ puts " curl http://localhost:3000/async-demo"
72
+ puts ""
73
+
74
+ FunApi::Server::Falcon.start(app, port: 3000)
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= title %></title>
7
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
8
+ <style>
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
12
+ max-width: 600px;
13
+ margin: 40px auto;
14
+ padding: 20px;
15
+ background: #f5f5f5;
16
+ }
17
+ h1 { color: #333; }
18
+ .todo-form { display: flex; gap: 10px; margin-bottom: 20px; }
19
+ .todo-form input {
20
+ flex: 1;
21
+ padding: 10px;
22
+ border: 1px solid #ddd;
23
+ border-radius: 4px;
24
+ font-size: 16px;
25
+ }
26
+ .todo-form button {
27
+ padding: 10px 20px;
28
+ background: #007bff;
29
+ color: white;
30
+ border: none;
31
+ border-radius: 4px;
32
+ cursor: pointer;
33
+ font-size: 16px;
34
+ }
35
+ .todo-form button:hover { background: #0056b3; }
36
+ .todo-list { list-style: none; padding: 0; }
37
+ .todo-item {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 10px;
41
+ padding: 15px;
42
+ background: white;
43
+ margin-bottom: 10px;
44
+ border-radius: 4px;
45
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
46
+ }
47
+ .todo-item.completed span { text-decoration: line-through; color: #888; }
48
+ .todo-item span { flex: 1; }
49
+ .todo-item button {
50
+ padding: 5px 10px;
51
+ border: none;
52
+ border-radius: 4px;
53
+ cursor: pointer;
54
+ }
55
+ .btn-toggle { background: #28a745; color: white; }
56
+ .btn-toggle:hover { background: #218838; }
57
+ .btn-delete { background: #dc3545; color: white; }
58
+ .btn-delete:hover { background: #c82333; }
59
+ .htmx-request { opacity: 0.5; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <h1><%= title %></h1>
64
+ <%= yield_content %>
65
+ </body>
66
+ </html>
@@ -0,0 +1,15 @@
1
+ <li id="todo-<%= todo[:id] %>" class="todo-item<%= " completed" if todo[:completed] %>">
2
+ <span><%= todo[:title] %></span>
3
+ <button class="btn-toggle"
4
+ hx-patch="/todos/<%= todo[:id] %>/toggle"
5
+ hx-target="#todo-<%= todo[:id] %>"
6
+ hx-swap="outerHTML">
7
+ <%= todo[:completed] ? "Undo" : "Done" %>
8
+ </button>
9
+ <button class="btn-delete"
10
+ hx-delete="/todos/<%= todo[:id] %>"
11
+ hx-target="#todo-<%= todo[:id] %>"
12
+ hx-swap="outerHTML">
13
+ Delete
14
+ </button>
15
+ </li>
@@ -0,0 +1,12 @@
1
+ <form class="todo-form" hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend" hx-on::after-request="this.reset()">
2
+ <input type="text" name="title" placeholder="What needs to be done?" required>
3
+ <button type="submit">Add</button>
4
+ </form>
5
+
6
+ <ul id="todo-list" class="todo-list">
7
+ <% todos.each do |todo| -%>
8
+ <%= render_partial("todos/_todo.html.erb", todo: todo) %>
9
+ <% end -%>
10
+ </ul>
11
+
12
+ <p><small>Powered by FunApi + HTMX</small></p>
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/funapi"
4
+ require_relative "../lib/fun_api/templates"
5
+ require_relative "../lib/funapi/server/falcon"
6
+
7
+ TODOS = [
8
+ {id: 1, title: "Learn FunApi", completed: true},
9
+ {id: 2, title: "Build something with HTMX", completed: false},
10
+ {id: 3, title: "Deploy to production", completed: false}
11
+ ]
12
+
13
+ TodoSchema = FunApi::Schema.define do
14
+ required(:title).filled(:string)
15
+ end
16
+
17
+ templates = FunApi::Templates.new(
18
+ directory: File.join(__dir__, "templates"),
19
+ layout: "layouts/application.html.erb"
20
+ )
21
+
22
+ app = FunApi::App.new(
23
+ title: "HTMX Todo Demo",
24
+ version: "1.0.0",
25
+ description: "A simple todo app demonstrating FunApi templates with HTMX"
26
+ ) do |api|
27
+ api.get "/" do |_input, _req, _task|
28
+ templates.response("todos/index.html.erb",
29
+ title: "Todo List",
30
+ todos: TODOS)
31
+ end
32
+
33
+ api.post "/todos", body: TodoSchema do |input, _req, _task|
34
+ new_id = (TODOS.map { |t| t[:id] }.max || 0) + 1
35
+ todo = {
36
+ id: new_id,
37
+ title: input[:body][:title],
38
+ completed: false
39
+ }
40
+ TODOS << todo
41
+
42
+ templates.response("todos/_todo.html.erb",
43
+ layout: false,
44
+ todo: todo,
45
+ status: 201)
46
+ end
47
+
48
+ api.patch "/todos/:id/toggle" do |input, _req, _task|
49
+ todo_id = input[:path]["id"].to_i
50
+ todo = TODOS.find { |t| t[:id] == todo_id }
51
+
52
+ unless todo
53
+ raise FunApi::HTTPException.new(status_code: 404, detail: "Todo not found")
54
+ end
55
+
56
+ todo[:completed] = !todo[:completed]
57
+
58
+ templates.response("todos/_todo.html.erb",
59
+ layout: false,
60
+ todo: todo)
61
+ end
62
+
63
+ api.delete "/todos/:id" do |input, _req, _task|
64
+ todo_id = input[:path]["id"].to_i
65
+ TODOS.reject! { |t| t[:id] == todo_id }
66
+
67
+ FunApi::TemplateResponse.new("")
68
+ end
69
+
70
+ api.get "/api/todos" do |_input, _req, _task|
71
+ [TODOS, 200]
72
+ end
73
+ end
74
+
75
+ puts "Starting HTMX Todo Demo..."
76
+ puts "Open http://localhost:3000 in your browser"
77
+ puts ""
78
+ puts "Features:"
79
+ puts " - Add todos with the form"
80
+ puts " - Toggle completion with 'Done' button"
81
+ puts " - Delete todos with 'Delete' button"
82
+ puts " - All updates happen without page reload (HTMX)"
83
+ puts ""
84
+ puts "JSON API also available at /api/todos"
85
+ puts ""
86
+
87
+ FunApi::Server::Falcon.start(app, port: 3000)