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,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)
|