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,704 @@
1
+ # Template Rendering Implementation Plan
2
+
3
+ ## Date: 2024-12-24
4
+
5
+ ## Overview
6
+
7
+ Implement template rendering support for FunApi, inspired by FastAPI's Jinja2Templates feature. This enables FunApi to pair well with hypermedia libraries like HTMX for HTML-centric applications.
8
+
9
+ ## Goals
10
+
11
+ 1. Support ERB template rendering (Ruby stdlib)
12
+ 2. Provide FastAPI-style convenience with Ruby idioms
13
+ 3. Enable easy HTML response generation for HTMX-style applications
14
+ 4. Keep implementation minimal and focused
15
+
16
+ ## FastAPI Approach (Reference)
17
+
18
+ ```python
19
+ from fastapi.templating import Jinja2Templates
20
+
21
+ templates = Jinja2Templates(directory="templates")
22
+
23
+ @app.get("/items/{id}", response_class=HTMLResponse)
24
+ async def read_item(request: Request, id: str):
25
+ return templates.TemplateResponse(
26
+ request=request, name="item.html", context={"id": id}
27
+ )
28
+ ```
29
+
30
+ ## Template Engine
31
+
32
+ **Decision: ERB Only**
33
+
34
+ ERB (Embedded Ruby) is Ruby's built-in template engine:
35
+ - Zero additional dependencies
36
+ - Familiar to all Ruby developers
37
+ - Aligns with FunApi's minimal philosophy
38
+ - Same engine Rails uses (familiarity)
39
+
40
+ ## API Design
41
+
42
+ **Decision: Option A - Explicit Templates Object**
43
+
44
+ ```ruby
45
+ templates = FunApi::Templates.new(directory: 'templates')
46
+
47
+ app = FunApi::App.new do |api|
48
+ api.get '/items/:id' do |input, req, task|
49
+ templates.response('item.html.erb', id: input[:path]['id'])
50
+ end
51
+ end
52
+ ```
53
+
54
+ **Rationale:**
55
+ - Most explicit - clear where templates come from
56
+ - Templates object can be shared/reused across files
57
+ - Can have multiple template directories
58
+ - No App class changes needed
59
+ - Testable in isolation
60
+
61
+ ## Implementation Design
62
+
63
+ ### 1. Templates Class
64
+
65
+ ```ruby
66
+ # lib/fun_api/templates.rb
67
+ module FunApi
68
+ class Templates
69
+ def initialize(directory:, layout: nil)
70
+ @directory = Pathname.new(directory)
71
+ @layout = layout
72
+ @cache = {}
73
+ end
74
+
75
+ def render(name, layout: nil, **context)
76
+ content = render_template(name, **context)
77
+
78
+ layout_to_use = layout.nil? ? @layout : layout
79
+ if layout_to_use
80
+ render_template(layout_to_use, **context) { content }
81
+ else
82
+ content
83
+ end
84
+ end
85
+
86
+ def response(name, status: 200, headers: {}, layout: nil, **context)
87
+ html = render(name, layout: layout, **context)
88
+ TemplateResponse.new(html, status: status, headers: headers)
89
+ end
90
+
91
+ def render_partial(name, **context)
92
+ render_template(name, **context)
93
+ end
94
+
95
+ private
96
+
97
+ def render_template(name, **context)
98
+ template = load_template(name)
99
+ binding_with_context = create_binding(context)
100
+ template.result(binding_with_context) { yield if block_given? }
101
+ end
102
+
103
+ def load_template(name)
104
+ path = @directory.join(name)
105
+ raise TemplateNotFoundError.new(name) unless path.exist?
106
+
107
+ @cache[name] ||= ERB.new(path.read, trim_mode: '-')
108
+ end
109
+
110
+ def create_binding(context)
111
+ template_binding = TemplateContext.new(self, context).get_binding
112
+ template_binding
113
+ end
114
+ end
115
+
116
+ class TemplateContext
117
+ def initialize(templates, context)
118
+ @templates = templates
119
+ context.each do |key, value|
120
+ instance_variable_set("@#{key}", value)
121
+ define_singleton_method(key) { value }
122
+ end
123
+ end
124
+
125
+ def render_partial(name, **context)
126
+ @templates.render_partial(name, **context)
127
+ end
128
+
129
+ def get_binding
130
+ binding
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ ### 2. TemplateResponse Class
137
+
138
+ ```ruby
139
+ # lib/fun_api/template_response.rb
140
+ module FunApi
141
+ class TemplateResponse
142
+ attr_reader :body, :status, :headers
143
+
144
+ def initialize(body, status: 200, headers: {})
145
+ @body = body
146
+ @status = status
147
+ @headers = { 'content-type' => 'text/html; charset=utf-8' }.merge(headers)
148
+ end
149
+
150
+ def to_response
151
+ [status, headers, [body]]
152
+ end
153
+ end
154
+ end
155
+ ```
156
+
157
+ ### 3. TemplateNotFoundError Exception
158
+
159
+ ```ruby
160
+ # Add to lib/fun_api/exceptions.rb
161
+ class TemplateNotFoundError < StandardError
162
+ attr_reader :template_name
163
+
164
+ def initialize(template_name)
165
+ @template_name = template_name
166
+ super("Template not found: #{template_name}")
167
+ end
168
+ end
169
+ ```
170
+
171
+ ### 4. Route Handler Integration
172
+
173
+ Modify `handle_async_route` in application.rb to detect TemplateResponse:
174
+
175
+ ```ruby
176
+ def handle_async_route(req, path_params, body_schema, query_schema, response_schema, dependencies, &blk)
177
+ # ... existing setup ...
178
+
179
+ begin
180
+ # ... validation ...
181
+
182
+ payload, status = blk.call(input, req, current_task, **resolved_deps)
183
+
184
+ # NEW: Detect TemplateResponse and return early
185
+ if payload.is_a?(TemplateResponse)
186
+ background_tasks.execute
187
+ return payload.to_response
188
+ end
189
+
190
+ # ... existing JSON handling ...
191
+ end
192
+ end
193
+ ```
194
+
195
+ ## File Structure
196
+
197
+ ```
198
+ lib/fun_api/
199
+ ├── templates.rb # Templates class + TemplateContext
200
+ ├── template_response.rb # TemplateResponse class
201
+ └── exceptions.rb # Add TemplateNotFoundError
202
+
203
+ examples/
204
+ └── templates_demo.rb # HTMX example
205
+
206
+ test/
207
+ ├── test_templates.rb # Template tests
208
+ └── fixtures/
209
+ └── templates/ # Test templates
210
+ ├── hello.html.erb
211
+ ├── user.html.erb
212
+ ├── with_partial.html.erb
213
+ ├── _item.html.erb
214
+ ├── layouts/
215
+ │ └── application.html.erb
216
+ └── items/
217
+ └── show.html.erb
218
+ ```
219
+
220
+ ## Usage Examples
221
+
222
+ ### Basic Template Rendering
223
+
224
+ ```ruby
225
+ require 'fun_api'
226
+ require 'fun_api/templates'
227
+
228
+ templates = FunApi::Templates.new(directory: 'templates')
229
+
230
+ app = FunApi::App.new do |api|
231
+ api.get '/' do |input, req, task|
232
+ templates.response('index.html.erb', title: 'Home', message: 'Welcome!')
233
+ end
234
+
235
+ api.get '/users/:id' do |input, req, task|
236
+ user = { id: input[:path]['id'], name: 'Alice' }
237
+ templates.response('user.html.erb', user: user)
238
+ end
239
+ end
240
+ ```
241
+
242
+ ### With Layouts
243
+
244
+ ```ruby
245
+ templates = FunApi::Templates.new(
246
+ directory: 'templates',
247
+ layout: 'layouts/application.html.erb'
248
+ )
249
+
250
+ app = FunApi::App.new do |api|
251
+ api.get '/' do |input, req, task|
252
+ templates.response('home.html.erb', title: 'Home')
253
+ end
254
+
255
+ # Override layout for specific route
256
+ api.get '/plain' do |input, req, task|
257
+ templates.response('plain.html.erb', layout: false, content: 'No layout')
258
+ end
259
+
260
+ # Use different layout
261
+ api.get '/admin' do |input, req, task|
262
+ templates.response('admin.html.erb', layout: 'layouts/admin.html.erb', title: 'Admin')
263
+ end
264
+ end
265
+ ```
266
+
267
+ ```html
268
+ <!-- templates/layouts/application.html.erb -->
269
+ <!DOCTYPE html>
270
+ <html>
271
+ <head>
272
+ <title><%= title %></title>
273
+ </head>
274
+ <body>
275
+ <header>My App</header>
276
+ <main>
277
+ <%= yield %>
278
+ </main>
279
+ <footer>Footer</footer>
280
+ </body>
281
+ </html>
282
+ ```
283
+
284
+ ```html
285
+ <!-- templates/home.html.erb -->
286
+ <h1>Welcome to <%= title %></h1>
287
+ <p>This is the home page.</p>
288
+ ```
289
+
290
+ ### With Partials
291
+
292
+ ```ruby
293
+ api.get '/items' do |input, req, task|
294
+ items = [
295
+ { id: 1, name: 'Item 1' },
296
+ { id: 2, name: 'Item 2' }
297
+ ]
298
+ templates.response('items/index.html.erb', items: items)
299
+ end
300
+ ```
301
+
302
+ ```html
303
+ <!-- templates/items/index.html.erb -->
304
+ <h1>Items</h1>
305
+ <ul>
306
+ <% items.each do |item| %>
307
+ <%= render_partial('items/_item.html.erb', item: item) %>
308
+ <% end %>
309
+ </ul>
310
+ ```
311
+
312
+ ```html
313
+ <!-- templates/items/_item.html.erb -->
314
+ <li id="item-<%= item[:id] %>"><%= item[:name] %></li>
315
+ ```
316
+
317
+ ### With HTMX
318
+
319
+ ```ruby
320
+ require 'fun_api'
321
+ require 'fun_api/templates'
322
+
323
+ templates = FunApi::Templates.new(
324
+ directory: 'templates',
325
+ layout: 'layouts/application.html.erb'
326
+ )
327
+
328
+ ItemSchema = FunApi::Schema.define do
329
+ required(:name).filled(:string)
330
+ end
331
+
332
+ app = FunApi::App.new do |api|
333
+ api.get '/items' do |input, req, task|
334
+ items = fetch_items
335
+ templates.response('items/index.html.erb', items: items)
336
+ end
337
+
338
+ # Return partial (no layout) for HTMX requests
339
+ api.post '/items', body: ItemSchema do |input, req, task|
340
+ item = create_item(input[:body])
341
+ templates.response('items/_item.html.erb', layout: false, item: item, status: 201)
342
+ end
343
+
344
+ api.delete '/items/:id' do |input, req, task|
345
+ delete_item(input[:path]['id'])
346
+ FunApi::TemplateResponse.new('', status: 200)
347
+ end
348
+ end
349
+ ```
350
+
351
+ ## Implementation Phases
352
+
353
+ ### Phase 1: Core Template Rendering (MVP)
354
+
355
+ 1. `FunApi::Templates` class with ERB support
356
+ 2. `FunApi::TemplateResponse` class
357
+ 3. `TemplateNotFoundError` exception
358
+ 4. Detection in route handler
359
+ 5. Comprehensive tests with real examples
360
+
361
+ ### Phase 2: Layouts and Partials
362
+
363
+ 1. Layout support with `yield`
364
+ 2. Per-route layout override (`layout: false`, `layout: 'other.html.erb'`)
365
+ 3. `render_partial` helper within templates
366
+ 4. Additional tests for layouts/partials
367
+
368
+ ### Phase 3: Demo and Documentation
369
+
370
+ 1. HTMX demo example
371
+ 2. Update README with templates section
372
+
373
+ ## Testing Strategy
374
+
375
+ ### Test Fixtures
376
+
377
+ ```
378
+ test/fixtures/templates/
379
+ ├── hello.html.erb # Basic: "Hello, <%= name %>!"
380
+ ├── user.html.erb # Object: "<p>User: <%= user[:name] %></p>"
381
+ ├── items.html.erb # Loop: items.each
382
+ ├── conditional.html.erb # If/else
383
+ ├── _item.html.erb # Partial
384
+ ├── with_partial.html.erb # Uses render_partial
385
+ ├── with_yield.html.erb # Has <%= yield %>
386
+ ├── layouts/
387
+ │ ├── application.html.erb # Standard layout with yield
388
+ │ └── admin.html.erb # Alternative layout
389
+ └── nested/
390
+ └── deep.html.erb # Nested directory test
391
+ ```
392
+
393
+ ### Test Cases
394
+
395
+ ```ruby
396
+ class TestTemplates < Minitest::Test
397
+ def setup
398
+ @template_dir = Pathname.new(__dir__).join('fixtures/templates')
399
+ @templates = FunApi::Templates.new(directory: @template_dir)
400
+ end
401
+
402
+ def async_request(app, method, path, **options)
403
+ Async do
404
+ Rack::MockRequest.new(app).send(method, path, **options)
405
+ end.wait
406
+ end
407
+
408
+ # === Basic Rendering ===
409
+
410
+ def test_renders_template_with_string_variable
411
+ html = @templates.render('hello.html.erb', name: 'World')
412
+ assert_includes html, 'Hello, World!'
413
+ end
414
+
415
+ def test_renders_template_with_hash_variable
416
+ html = @templates.render('user.html.erb', user: { id: 1, name: 'Alice' })
417
+ assert_includes html, 'Alice'
418
+ end
419
+
420
+ def test_renders_template_with_array_and_loop
421
+ html = @templates.render('items.html.erb', items: ['A', 'B', 'C'])
422
+ assert_includes html, 'A'
423
+ assert_includes html, 'B'
424
+ assert_includes html, 'C'
425
+ end
426
+
427
+ def test_renders_template_with_conditionals
428
+ html_true = @templates.render('conditional.html.erb', show: true)
429
+ html_false = @templates.render('conditional.html.erb', show: false)
430
+
431
+ assert_includes html_true, 'Visible'
432
+ refute_includes html_false, 'Visible'
433
+ end
434
+
435
+ def test_renders_nested_template
436
+ html = @templates.render('nested/deep.html.erb', value: 'test')
437
+ assert_includes html, 'test'
438
+ end
439
+
440
+ # === TemplateResponse ===
441
+
442
+ def test_response_returns_template_response
443
+ response = @templates.response('hello.html.erb', name: 'Test')
444
+
445
+ assert_instance_of FunApi::TemplateResponse, response
446
+ assert_equal 200, response.status
447
+ assert_equal 'text/html; charset=utf-8', response.headers['content-type']
448
+ assert_includes response.body, 'Hello, Test!'
449
+ end
450
+
451
+ def test_response_with_custom_status
452
+ response = @templates.response('hello.html.erb', status: 201, name: 'Created')
453
+ assert_equal 201, response.status
454
+ end
455
+
456
+ def test_response_with_custom_headers
457
+ response = @templates.response('hello.html.erb',
458
+ headers: { 'x-custom' => 'value' },
459
+ name: 'Test')
460
+
461
+ assert_equal 'value', response.headers['x-custom']
462
+ assert_equal 'text/html; charset=utf-8', response.headers['content-type']
463
+ end
464
+
465
+ def test_template_response_to_response
466
+ response = FunApi::TemplateResponse.new('<p>Test</p>', status: 201)
467
+ status, headers, body = response.to_response
468
+
469
+ assert_equal 201, status
470
+ assert_equal 'text/html; charset=utf-8', headers['content-type']
471
+ assert_equal ['<p>Test</p>'], body
472
+ end
473
+
474
+ # === Layouts ===
475
+
476
+ def test_renders_with_default_layout
477
+ templates = FunApi::Templates.new(
478
+ directory: @template_dir,
479
+ layout: 'layouts/application.html.erb'
480
+ )
481
+
482
+ html = templates.render('hello.html.erb', name: 'World', title: 'Test')
483
+
484
+ assert_includes html, '<!DOCTYPE html>'
485
+ assert_includes html, '<title>Test</title>'
486
+ assert_includes html, 'Hello, World!'
487
+ end
488
+
489
+ def test_renders_without_layout_when_disabled
490
+ templates = FunApi::Templates.new(
491
+ directory: @template_dir,
492
+ layout: 'layouts/application.html.erb'
493
+ )
494
+
495
+ html = templates.render('hello.html.erb', layout: false, name: 'World')
496
+
497
+ refute_includes html, '<!DOCTYPE html>'
498
+ assert_includes html, 'Hello, World!'
499
+ end
500
+
501
+ def test_renders_with_different_layout
502
+ templates = FunApi::Templates.new(
503
+ directory: @template_dir,
504
+ layout: 'layouts/application.html.erb'
505
+ )
506
+
507
+ html = templates.render('hello.html.erb',
508
+ layout: 'layouts/admin.html.erb',
509
+ name: 'World',
510
+ title: 'Admin')
511
+
512
+ assert_includes html, 'Admin Layout'
513
+ assert_includes html, 'Hello, World!'
514
+ end
515
+
516
+ # === Partials ===
517
+
518
+ def test_render_partial
519
+ html = @templates.render('with_partial.html.erb',
520
+ items: [{ name: 'One' }, { name: 'Two' }])
521
+
522
+ assert_includes html, 'One'
523
+ assert_includes html, 'Two'
524
+ end
525
+
526
+ def test_render_partial_directly
527
+ html = @templates.render_partial('_item.html.erb', item: { name: 'Direct' })
528
+ assert_includes html, 'Direct'
529
+ end
530
+
531
+ # === Error Handling ===
532
+
533
+ def test_raises_on_missing_template
534
+ error = assert_raises(FunApi::TemplateNotFoundError) do
535
+ @templates.render('nonexistent.html.erb')
536
+ end
537
+
538
+ assert_equal 'nonexistent.html.erb', error.template_name
539
+ assert_includes error.message, 'Template not found'
540
+ end
541
+
542
+ def test_raises_on_missing_layout
543
+ templates = FunApi::Templates.new(
544
+ directory: @template_dir,
545
+ layout: 'layouts/missing.html.erb'
546
+ )
547
+
548
+ assert_raises(FunApi::TemplateNotFoundError) do
549
+ templates.render('hello.html.erb', name: 'Test')
550
+ end
551
+ end
552
+
553
+ # === Caching ===
554
+
555
+ def test_caches_compiled_templates
556
+ @templates.render('hello.html.erb', name: 'First')
557
+ @templates.render('hello.html.erb', name: 'Second')
558
+
559
+ assert_equal 1, @templates.instance_variable_get(:@cache).size
560
+ end
561
+
562
+ # === Route Integration ===
563
+
564
+ def test_integration_with_route_handler
565
+ templates = FunApi::Templates.new(directory: @template_dir)
566
+
567
+ app = FunApi::App.new do |api|
568
+ api.get '/test' do |input, req, task|
569
+ templates.response('hello.html.erb', name: 'Integration')
570
+ end
571
+ end
572
+
573
+ res = async_request(app, :get, '/test')
574
+ assert_equal 200, res.status
575
+ assert_includes res.body, 'Hello, Integration!'
576
+ assert_equal 'text/html; charset=utf-8', res['content-type']
577
+ end
578
+
579
+ def test_integration_with_path_params
580
+ templates = FunApi::Templates.new(directory: @template_dir)
581
+
582
+ app = FunApi::App.new do |api|
583
+ api.get '/users/:id' do |input, req, task|
584
+ templates.response('user.html.erb',
585
+ user: { id: input[:path]['id'], name: 'Test User' })
586
+ end
587
+ end
588
+
589
+ res = async_request(app, :get, '/users/42')
590
+ assert_equal 200, res.status
591
+ assert_includes res.body, 'Test User'
592
+ end
593
+
594
+ def test_integration_with_custom_status
595
+ templates = FunApi::Templates.new(directory: @template_dir)
596
+
597
+ app = FunApi::App.new do |api|
598
+ api.post '/items' do |input, req, task|
599
+ templates.response('hello.html.erb', status: 201, name: 'Created')
600
+ end
601
+ end
602
+
603
+ res = async_request(app, :post, '/items')
604
+ assert_equal 201, res.status
605
+ end
606
+
607
+ def test_integration_with_layout
608
+ templates = FunApi::Templates.new(
609
+ directory: @template_dir,
610
+ layout: 'layouts/application.html.erb'
611
+ )
612
+
613
+ app = FunApi::App.new do |api|
614
+ api.get '/' do |input, req, task|
615
+ templates.response('hello.html.erb', name: 'Home', title: 'My App')
616
+ end
617
+ end
618
+
619
+ res = async_request(app, :get, '/')
620
+ assert_equal 200, res.status
621
+ assert_includes res.body, '<!DOCTYPE html>'
622
+ assert_includes res.body, '<title>My App</title>'
623
+ assert_includes res.body, 'Hello, Home!'
624
+ end
625
+
626
+ def test_json_routes_still_work
627
+ templates = FunApi::Templates.new(directory: @template_dir)
628
+
629
+ app = FunApi::App.new do |api|
630
+ api.get '/html' do |input, req, task|
631
+ templates.response('hello.html.erb', name: 'HTML')
632
+ end
633
+
634
+ api.get '/json' do |input, req, task|
635
+ [{ message: 'JSON response' }, 200]
636
+ end
637
+ end
638
+
639
+ html_res = async_request(app, :get, '/html')
640
+ json_res = async_request(app, :get, '/json')
641
+
642
+ assert_equal 'text/html; charset=utf-8', html_res['content-type']
643
+ assert_equal 'application/json', json_res['content-type']
644
+ end
645
+ end
646
+ ```
647
+
648
+ ## Decisions Log
649
+
650
+ | Question | Decision | Rationale |
651
+ |----------|----------|-----------|
652
+ | Template engine | ERB only | Stdlib, zero deps, familiar |
653
+ | API style | Option A (explicit) | More explicit, flexible, testable |
654
+ | Layouts | Phase 2 (important) | Core feature for real apps |
655
+ | Partials | Phase 2 (important) | Essential for HTMX patterns |
656
+ | URL helpers | Not implementing | Keep simple |
657
+ | Hot reload | Out of scope | Future consideration |
658
+ | Other engines | Out of scope | ERB sufficient |
659
+ | Auto-require | No | Opt-in, like middleware |
660
+
661
+ ## Dependencies
662
+
663
+ None required - ERB is Ruby stdlib.
664
+
665
+ ## Success Criteria
666
+
667
+ 1. Can render ERB templates with context variables
668
+ 2. Returns proper HTML content-type headers
669
+ 3. Integrates seamlessly with existing route handlers
670
+ 4. Layout support with yield
671
+ 5. Partials with render_partial
672
+ 6. No new dependencies
673
+ 7. Follows FunApi's minimal philosophy
674
+ 8. Comprehensive test coverage with real examples
675
+ 9. JSON routes continue to work alongside HTML
676
+
677
+ ## Comparison to FastAPI
678
+
679
+ | FastAPI | FunApi |
680
+ |---------|--------|
681
+ | `Jinja2Templates(directory="...")` | `Templates.new(directory: "...")` |
682
+ | `templates.TemplateResponse(...)` | `templates.response(...)` |
683
+ | Jinja2 | ERB (native Ruby) |
684
+ | `{{ variable }}` | `<%= variable %>` |
685
+ | `{% for item in items %}` | `<% items.each do \|item\| %>` |
686
+ | `{% include 'partial.html' %}` | `<%= render_partial('_partial.html.erb', ...) %>` |
687
+ | `{% extends 'base.html' %}` | `layout: 'layouts/base.html.erb'` |
688
+
689
+ ## Next Steps
690
+
691
+ 1. Create `lib/fun_api/templates.rb` (Templates + TemplateContext classes)
692
+ 2. Create `lib/fun_api/template_response.rb`
693
+ 3. Add `TemplateNotFoundError` to exceptions.rb
694
+ 4. Modify `handle_async_route` to detect TemplateResponse
695
+ 5. Create test fixtures (template files)
696
+ 6. Write comprehensive tests
697
+ 7. Create HTMX demo example
698
+ 8. Update README
699
+
700
+ ---
701
+
702
+ **Status:** Planning Complete - Ready for Implementation
703
+ **Estimated Effort:** ~3-4 hours (includes layouts and partials)
704
+ **Priority:** Medium-High (enables HTMX use cases)