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.
- checksums.yaml +7 -0
- data/.claude/25-09-01-OPENAPI_IMPLEMENTATION.md +233 -0
- data/.claude/25-09-05-RESPONSE_SCHEMA.md +383 -0
- data/.claude/25-09-10-OPENAPI_PLAN.md +219 -0
- data/.claude/25-10-26-MIDDLEWARE_IMPLEMENTATION.md +230 -0
- data/.claude/25-10-26-MIDDLEWARE_PLAN.md +353 -0
- data/.claude/25-10-27-BACKGROUND_TASKS_ANALYSIS.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_IMPLEMENTATION_SUMMARY.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_INJECTION_PLAN.md +753 -0
- data/.claude/25-12-24-LIFECYCLE_HOOKS_PLAN.md +421 -0
- data/.claude/25-12-24-PUBLISHING_AND_DOGFOODING_PLAN.md +327 -0
- data/.claude/25-12-24-TEMPLATE_RENDERING_PLAN.md +704 -0
- data/.claude/DECISIONS.md +397 -0
- data/.claude/PROJECT_PLAN.md +80 -0
- data/.claude/TESTING_PLAN.md +285 -0
- data/.claude/TESTING_STATUS.md +157 -0
- data/.tool-versions +1 -0
- data/AGENTS.md +416 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +660 -0
- data/Rakefile +10 -0
- data/docs +8 -0
- data/docs-site/.gitignore +3 -0
- data/docs-site/Gemfile +9 -0
- data/docs-site/app.rb +138 -0
- data/docs-site/content/essential/handler.md +156 -0
- data/docs-site/content/essential/lifecycle.md +161 -0
- data/docs-site/content/essential/middleware.md +201 -0
- data/docs-site/content/essential/openapi.md +155 -0
- data/docs-site/content/essential/routing.md +123 -0
- data/docs-site/content/essential/validation.md +166 -0
- data/docs-site/content/getting-started/at-glance.md +82 -0
- data/docs-site/content/getting-started/key-concepts.md +150 -0
- data/docs-site/content/getting-started/quick-start.md +127 -0
- data/docs-site/content/index.md +81 -0
- data/docs-site/content/patterns/async-operations.md +137 -0
- data/docs-site/content/patterns/background-tasks.md +143 -0
- data/docs-site/content/patterns/database.md +175 -0
- data/docs-site/content/patterns/dependencies.md +141 -0
- data/docs-site/content/patterns/deployment.md +212 -0
- data/docs-site/content/patterns/error-handling.md +184 -0
- data/docs-site/content/patterns/response-schema.md +159 -0
- data/docs-site/content/patterns/templates.md +193 -0
- data/docs-site/content/patterns/testing.md +218 -0
- data/docs-site/mise.toml +2 -0
- data/docs-site/public/css/style.css +234 -0
- data/docs-site/templates/layouts/docs.html.erb +28 -0
- data/docs-site/templates/page.html.erb +3 -0
- data/docs-site/templates/partials/_nav.html.erb +19 -0
- data/examples/background_tasks_demo.rb +159 -0
- data/examples/demo_middleware.rb +55 -0
- data/examples/demo_openapi.rb +63 -0
- data/examples/dependency_block_demo.rb +150 -0
- data/examples/dependency_cleanup_demo.rb +146 -0
- data/examples/dependency_injection_demo.rb +200 -0
- data/examples/lifecycle_demo.rb +57 -0
- data/examples/middleware_demo.rb +74 -0
- data/examples/templates/layouts/application.html.erb +66 -0
- data/examples/templates/todos/_todo.html.erb +15 -0
- data/examples/templates/todos/index.html.erb +12 -0
- data/examples/templates_demo.rb +87 -0
- data/lib/funapi/application.rb +521 -0
- data/lib/funapi/async.rb +57 -0
- data/lib/funapi/background_tasks.rb +52 -0
- data/lib/funapi/config.rb +23 -0
- data/lib/funapi/database/sequel/fibered_connection_pool.rb +87 -0
- data/lib/funapi/dependency_wrapper.rb +66 -0
- data/lib/funapi/depends.rb +138 -0
- data/lib/funapi/exceptions.rb +72 -0
- data/lib/funapi/middleware/base.rb +13 -0
- data/lib/funapi/middleware/cors.rb +23 -0
- data/lib/funapi/middleware/request_logger.rb +32 -0
- data/lib/funapi/middleware/trusted_host.rb +34 -0
- data/lib/funapi/middleware.rb +4 -0
- data/lib/funapi/openapi/schema_converter.rb +85 -0
- data/lib/funapi/openapi/spec_generator.rb +179 -0
- data/lib/funapi/router.rb +43 -0
- data/lib/funapi/schema.rb +65 -0
- data/lib/funapi/server/falcon.rb +38 -0
- data/lib/funapi/template_response.rb +17 -0
- data/lib/funapi/templates.rb +111 -0
- data/lib/funapi/version.rb +5 -0
- data/lib/funapi.rb +14 -0
- data/sig/fun_api.rbs +499 -0
- 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)
|