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,753 @@
|
|
|
1
|
+
# Dependency Injection Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Date: 2024-10-27
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Implement FastAPI-style dependency injection for FunApi, providing a flexible, lightweight system that allows injecting dependencies into route handlers without heavy external frameworks.
|
|
8
|
+
|
|
9
|
+
## Research Summary
|
|
10
|
+
|
|
11
|
+
### FastAPI's Dependency Injection
|
|
12
|
+
|
|
13
|
+
**Core Mechanism:**
|
|
14
|
+
```python
|
|
15
|
+
from fastapi import Depends
|
|
16
|
+
|
|
17
|
+
def common_parameters(q: str = None):
|
|
18
|
+
return {"q": q}
|
|
19
|
+
|
|
20
|
+
@app.get("/items/")
|
|
21
|
+
async def read_items(commons: dict = Depends(common_parameters)):
|
|
22
|
+
return commons
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Key Features:**
|
|
26
|
+
1. Dependencies are functions that can take same parameters as path operations
|
|
27
|
+
2. `Depends()` wrapper marks parameters as dependencies
|
|
28
|
+
3. Dependencies can have sub-dependencies (nested)
|
|
29
|
+
4. Dependencies can be callable classes (using `__init__` for params, `__call__` for execution)
|
|
30
|
+
5. Dependencies with `yield` for setup/teardown
|
|
31
|
+
6. Type aliases for reusable dependency declarations
|
|
32
|
+
7. Integrated with OpenAPI documentation
|
|
33
|
+
|
|
34
|
+
**Execution Flow:**
|
|
35
|
+
1. FastAPI inspects route handler signature
|
|
36
|
+
2. Identifies parameters with `Depends()`
|
|
37
|
+
3. Executes dependency functions with their own dependencies
|
|
38
|
+
4. Injects results into route handler parameters
|
|
39
|
+
|
|
40
|
+
### Ruby Ecosystem Review
|
|
41
|
+
|
|
42
|
+
**dry-system + dry-auto_inject:**
|
|
43
|
+
- Full-featured DI framework
|
|
44
|
+
- Constructor injection focused
|
|
45
|
+
- Requires container setup, auto-registration
|
|
46
|
+
- Heavy for FunApi's minimal philosophy
|
|
47
|
+
|
|
48
|
+
**dry-container:**
|
|
49
|
+
- Simple key-value container
|
|
50
|
+
- Manual registration required
|
|
51
|
+
- Good for complex apps but overkill for our use case
|
|
52
|
+
|
|
53
|
+
**Recommendation: Custom Lightweight Implementation**
|
|
54
|
+
- Align with FunApi's minimal, explicit philosophy
|
|
55
|
+
- FastAPI-like API adapted to Ruby idioms
|
|
56
|
+
- No heavy dependencies beyond what we already use
|
|
57
|
+
- Clear, simple implementation users can understand
|
|
58
|
+
|
|
59
|
+
## Design
|
|
60
|
+
|
|
61
|
+
### API Design (Ruby-Idiomatic)
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# Define a dependency (just a proc, lambda, or method)
|
|
65
|
+
def get_db
|
|
66
|
+
Database.connect
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_current_user(req:, db: Depends(get_db))
|
|
70
|
+
token = req.env['HTTP_AUTHORIZATION']
|
|
71
|
+
db.find_user_by_token(token) || raise FunApi::HTTPException.new(status_code: 401)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Use in routes
|
|
75
|
+
api.get '/users/:id', deps: { db: get_db } do |input, req, task, db:|
|
|
76
|
+
user = db.find(input[:path]['id'])
|
|
77
|
+
[user, 200]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Or with Depends for sub-dependencies
|
|
81
|
+
api.get '/profile', deps: { user: get_current_user } do |input, req, task, user:|
|
|
82
|
+
[user, 200]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Class-based dependencies (callable)
|
|
86
|
+
class Paginator
|
|
87
|
+
def initialize(max_limit: 100)
|
|
88
|
+
@max_limit = max_limit
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def call(limit: 10, offset: 0)
|
|
92
|
+
{
|
|
93
|
+
limit: [limit, @max_limit].min,
|
|
94
|
+
offset: offset
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
pagination = Paginator.new(max_limit: 50)
|
|
100
|
+
|
|
101
|
+
api.get '/items', deps: { page: pagination } do |input, req, task, page:|
|
|
102
|
+
[{ pagination: page }, 200]
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Alternative Syntax (More Ruby-like)
|
|
107
|
+
|
|
108
|
+
After consideration, let's use a simpler approach:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Dependencies as keyword arguments to route
|
|
112
|
+
api.get '/users/:id',
|
|
113
|
+
depends: { db: -> { Database.connect } } do |input, req, task, db:|
|
|
114
|
+
user = db.find(input[:path]['id'])
|
|
115
|
+
[user, 200]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Or use a helper for cleaner syntax
|
|
119
|
+
def db_connection
|
|
120
|
+
-> { Database.connect }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
api.get '/users/:id',
|
|
124
|
+
depends: { db: db_connection } do |input, req, task, db:|
|
|
125
|
+
user = db.find(input[:path]['id'])
|
|
126
|
+
[user, 200]
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Core Components
|
|
131
|
+
|
|
132
|
+
1. **FunApi::Depends** - Wrapper class to mark dependencies with sub-dependencies
|
|
133
|
+
2. **Dependency Resolution** - Mechanism to resolve dependency graph
|
|
134
|
+
3. **Route Handler Enhancement** - Inject resolved dependencies as keyword args
|
|
135
|
+
4. **OpenAPI Integration** - Document dependencies in spec
|
|
136
|
+
|
|
137
|
+
### Implementation Strategy
|
|
138
|
+
|
|
139
|
+
**Phase 1: Core Functionality**
|
|
140
|
+
1. Create `FunApi::Depends` class
|
|
141
|
+
2. Add `depends:` parameter to route definition
|
|
142
|
+
3. Implement dependency resolution in request handling
|
|
143
|
+
4. Support simple dependencies (procs/lambdas)
|
|
144
|
+
|
|
145
|
+
**Phase 2: Advanced Features**
|
|
146
|
+
5. Support class-based dependencies (callable)
|
|
147
|
+
6. Support sub-dependencies (nested Depends)
|
|
148
|
+
7. Handle request-scoped dependencies
|
|
149
|
+
|
|
150
|
+
**Phase 3: Integration**
|
|
151
|
+
8. OpenAPI documentation
|
|
152
|
+
9. Error handling for dependency failures
|
|
153
|
+
10. Testing utilities
|
|
154
|
+
|
|
155
|
+
## Detailed Implementation
|
|
156
|
+
|
|
157
|
+
### 1. FunApi::Depends Class
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# lib/fun_api/depends.rb
|
|
161
|
+
module FunApi
|
|
162
|
+
class Depends
|
|
163
|
+
attr_reader :dependency, :kwargs
|
|
164
|
+
|
|
165
|
+
def initialize(dependency, **kwargs)
|
|
166
|
+
@dependency = dependency
|
|
167
|
+
@kwargs = kwargs
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def call(context)
|
|
171
|
+
resolved_kwargs = resolve_kwargs(context)
|
|
172
|
+
|
|
173
|
+
if @dependency.respond_to?(:call)
|
|
174
|
+
if @dependency.arity == 0 || @dependency.arity == -1
|
|
175
|
+
# Lambda/Proc with no args or variable args
|
|
176
|
+
@dependency.call(**resolved_kwargs)
|
|
177
|
+
else
|
|
178
|
+
@dependency.call(**resolved_kwargs)
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
raise ArgumentError, "Dependency must be callable"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def resolve_kwargs(context)
|
|
188
|
+
@kwargs.transform_values do |value|
|
|
189
|
+
if value.is_a?(Depends)
|
|
190
|
+
value.call(context)
|
|
191
|
+
elsif value.respond_to?(:call)
|
|
192
|
+
value.call
|
|
193
|
+
else
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### 2. Route Definition Enhancement
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# In lib/fun_api/application.rb
|
|
206
|
+
|
|
207
|
+
def add_route(verb, path, body: nil, query: nil, response_schema: nil, depends: {}, &handler)
|
|
208
|
+
route = {
|
|
209
|
+
handler: handler,
|
|
210
|
+
body_schema: body,
|
|
211
|
+
query_schema: query,
|
|
212
|
+
response_schema: response_schema,
|
|
213
|
+
dependencies: normalize_dependencies(depends)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
router.add_route(verb, path, route)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
def normalize_dependencies(depends)
|
|
222
|
+
depends.transform_values do |dep|
|
|
223
|
+
if dep.is_a?(Depends)
|
|
224
|
+
dep
|
|
225
|
+
elsif dep.respond_to?(:call)
|
|
226
|
+
Depends.new(dep)
|
|
227
|
+
else
|
|
228
|
+
raise ArgumentError, "Dependency must be callable or Depends instance"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 3. Dependency Resolution in Request Handling
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
# In lib/fun_api/application.rb (call method)
|
|
238
|
+
|
|
239
|
+
def call(env)
|
|
240
|
+
Async do |task|
|
|
241
|
+
# ... existing route matching code ...
|
|
242
|
+
|
|
243
|
+
# Resolve dependencies
|
|
244
|
+
dependency_context = {
|
|
245
|
+
input: validated_input,
|
|
246
|
+
req: request,
|
|
247
|
+
task: task
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
resolved_deps = resolve_dependencies(
|
|
251
|
+
route[:dependencies],
|
|
252
|
+
dependency_context
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Call handler with dependencies as keyword arguments
|
|
256
|
+
response_data, status_code = handler.call(
|
|
257
|
+
validated_input,
|
|
258
|
+
request,
|
|
259
|
+
task,
|
|
260
|
+
**resolved_deps
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# ... rest of response handling ...
|
|
264
|
+
end.wait
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def resolve_dependencies(dependencies, context)
|
|
270
|
+
dependencies.transform_values do |dep|
|
|
271
|
+
dep.call(context)
|
|
272
|
+
end
|
|
273
|
+
rescue => e
|
|
274
|
+
# Convert to HTTPException
|
|
275
|
+
raise FunApi::HTTPException.new(
|
|
276
|
+
status_code: 500,
|
|
277
|
+
detail: "Dependency resolution failed: #{e.message}"
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### 4. Support for Request Parameters in Dependencies
|
|
283
|
+
|
|
284
|
+
Dependencies should be able to access request data:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# Dependencies can declare what they need
|
|
288
|
+
api.get '/profile',
|
|
289
|
+
depends: {
|
|
290
|
+
user: ->(req:) {
|
|
291
|
+
token = req.env['HTTP_AUTHORIZATION']
|
|
292
|
+
find_user_by_token(token)
|
|
293
|
+
}
|
|
294
|
+
} do |input, req, task, user:|
|
|
295
|
+
[{ user: user }, 200]
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Implementation:
|
|
300
|
+
```ruby
|
|
301
|
+
class Depends
|
|
302
|
+
def call(context)
|
|
303
|
+
# Introspect what the dependency needs
|
|
304
|
+
params = {}
|
|
305
|
+
|
|
306
|
+
if @dependency.respond_to?(:parameters)
|
|
307
|
+
@dependency.parameters.each do |type, name|
|
|
308
|
+
next unless type == :keyreq || type == :key
|
|
309
|
+
|
|
310
|
+
# Provide from context
|
|
311
|
+
params[name] = context[name] if context.key?(name)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Merge with sub-dependencies
|
|
316
|
+
params.merge!(resolve_kwargs(context))
|
|
317
|
+
|
|
318
|
+
@dependency.call(**params)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### 5. Class-based Dependencies
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
class DatabaseConnection
|
|
327
|
+
def call(req:, task:)
|
|
328
|
+
# Use task for async operations if needed
|
|
329
|
+
task.async { connect_to_db }.wait
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
def connect_to_db
|
|
335
|
+
Database.connect
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
db_dep = DatabaseConnection.new
|
|
340
|
+
|
|
341
|
+
api.get '/users',
|
|
342
|
+
depends: { db: db_dep } do |input, req, task, db:|
|
|
343
|
+
users = db.all_users
|
|
344
|
+
[users, 200]
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### 6. Parameterized Dependencies (Callable Classes)
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
class Paginator
|
|
352
|
+
def initialize(max_limit: 100)
|
|
353
|
+
@max_limit = max_limit
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def call(input:)
|
|
357
|
+
limit = [input[:query][:limit] || 10, @max_limit].min
|
|
358
|
+
offset = input[:query][:offset] || 0
|
|
359
|
+
{ limit: limit, offset: offset }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
api.get '/items',
|
|
364
|
+
query: PaginationSchema,
|
|
365
|
+
depends: { page: Paginator.new(max_limit: 50) } do |input, req, task, page:|
|
|
366
|
+
items = Item.limit(page[:limit]).offset(page[:offset])
|
|
367
|
+
[items, 200]
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### 7. Nested Dependencies (Sub-dependencies)
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
def get_db
|
|
375
|
+
->(task:) {
|
|
376
|
+
task.async { Database.connect }.wait
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def get_current_user
|
|
381
|
+
->(req:, db: FunApi::Depends(get_db)) {
|
|
382
|
+
token = req.env['HTTP_AUTHORIZATION']
|
|
383
|
+
db.find_user_by_token(token) || raise FunApi::HTTPException.new(status_code: 401)
|
|
384
|
+
}
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
api.get '/profile',
|
|
388
|
+
depends: { user: get_current_user } do |input, req, task, user:|
|
|
389
|
+
[user, 200]
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Testing Strategy
|
|
394
|
+
|
|
395
|
+
### Unit Tests
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
# test/test_depends.rb
|
|
399
|
+
class TestDepends < Minitest::Test
|
|
400
|
+
def test_simple_dependency
|
|
401
|
+
dep = FunApi::Depends.new(-> { "hello" })
|
|
402
|
+
result = dep.call({})
|
|
403
|
+
assert_equal "hello", result
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def test_dependency_with_context
|
|
407
|
+
dep = FunApi::Depends.new(->(req:) { req[:value] })
|
|
408
|
+
result = dep.call(req: { value: 42 })
|
|
409
|
+
assert_equal 42, result
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def test_nested_dependencies
|
|
413
|
+
db = FunApi::Depends.new(-> { "db_connection" })
|
|
414
|
+
user = FunApi::Depends.new(->(db:) { "user_from_#{db}" }, db: db)
|
|
415
|
+
|
|
416
|
+
result = user.call({})
|
|
417
|
+
assert_equal "user_from_db_connection", result
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# test/test_dependency_injection.rb
|
|
422
|
+
class TestDependencyInjection < Minitest::Test
|
|
423
|
+
def test_route_with_simple_dependency
|
|
424
|
+
app = FunApi::App.new do |api|
|
|
425
|
+
api.get '/test',
|
|
426
|
+
depends: { value: -> { 42 } } do |input, req, task, value:|
|
|
427
|
+
[{ value: value }, 200]
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
res = async_request(app, :get, '/test')
|
|
432
|
+
assert_equal 200, res.status
|
|
433
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
|
434
|
+
assert_equal 42, data[:value]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def test_route_with_request_context_dependency
|
|
438
|
+
app = FunApi::App.new do |api|
|
|
439
|
+
api.get '/test',
|
|
440
|
+
depends: {
|
|
441
|
+
auth: ->(req:) { req.env['HTTP_AUTHORIZATION'] }
|
|
442
|
+
} do |input, req, task, auth:|
|
|
443
|
+
[{ token: auth }, 200]
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
res = async_request(app, :get, '/test', 'HTTP_AUTHORIZATION' => 'Bearer token123')
|
|
448
|
+
assert_equal 200, res.status
|
|
449
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
|
450
|
+
assert_equal 'Bearer token123', data[:token]
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def test_route_with_nested_dependencies
|
|
454
|
+
app = FunApi::App.new do |api|
|
|
455
|
+
db = -> { { users: [{ id: 1, name: 'Alice' }] } }
|
|
456
|
+
user = ->(db:) { db[:users].first }
|
|
457
|
+
|
|
458
|
+
api.get '/user',
|
|
459
|
+
depends: {
|
|
460
|
+
current_user: FunApi::Depends.new(user, db: FunApi::Depends.new(db))
|
|
461
|
+
} do |input, req, task, current_user:|
|
|
462
|
+
[current_user, 200]
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
res = async_request(app, :get, '/user')
|
|
467
|
+
assert_equal 200, res.status
|
|
468
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
|
469
|
+
assert_equal 'Alice', data[:name]
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def test_class_based_dependency
|
|
473
|
+
class TestDep
|
|
474
|
+
def call
|
|
475
|
+
"from_class"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
app = FunApi::App.new do |api|
|
|
480
|
+
api.get '/test',
|
|
481
|
+
depends: { value: TestDep.new } do |input, req, task, value:|
|
|
482
|
+
[{ value: value }, 200]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
res = async_request(app, :get, '/test')
|
|
487
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
|
488
|
+
assert_equal "from_class", data[:value]
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Integration Tests
|
|
494
|
+
|
|
495
|
+
Test with real-world scenarios:
|
|
496
|
+
- Database connections
|
|
497
|
+
- Authentication/authorization
|
|
498
|
+
- Rate limiting
|
|
499
|
+
- Caching
|
|
500
|
+
|
|
501
|
+
## OpenAPI Integration
|
|
502
|
+
|
|
503
|
+
Dependencies should appear in OpenAPI spec when they affect request parameters:
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
# If dependency uses query params, document them
|
|
507
|
+
api.get '/items',
|
|
508
|
+
query: PaginationSchema,
|
|
509
|
+
depends: { page: Paginator.new } do |input, req, task, page:|
|
|
510
|
+
# The PaginationSchema is documented
|
|
511
|
+
# The page dependency transforms those params
|
|
512
|
+
end
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Dependencies themselves don't need to appear in OpenAPI (they're implementation details), but their effects on the API should be documented through schemas.
|
|
516
|
+
|
|
517
|
+
## Error Handling
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
# Dependency raises exception
|
|
521
|
+
def require_auth
|
|
522
|
+
->(req:) {
|
|
523
|
+
token = req.env['HTTP_AUTHORIZATION']
|
|
524
|
+
raise FunApi::HTTPException.new(
|
|
525
|
+
status_code: 401,
|
|
526
|
+
detail: "Not authenticated"
|
|
527
|
+
) unless token
|
|
528
|
+
verify_token(token)
|
|
529
|
+
}
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# FunApi should catch and convert to proper HTTP response
|
|
533
|
+
api.get '/protected',
|
|
534
|
+
depends: { user: require_auth } do |input, req, task, user:|
|
|
535
|
+
[{ user: user }, 200]
|
|
536
|
+
end
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Examples
|
|
540
|
+
|
|
541
|
+
### Authentication Example
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
# examples/dependency_auth.rb
|
|
545
|
+
require 'fun_api'
|
|
546
|
+
require 'fun_api/server/falcon'
|
|
547
|
+
|
|
548
|
+
class AuthError < FunApi::HTTPException
|
|
549
|
+
def initialize(detail = "Not authenticated")
|
|
550
|
+
super(status_code: 401, detail: detail)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
FAKE_DB = {
|
|
555
|
+
"token123" => { id: 1, name: "Alice", email: "alice@example.com" },
|
|
556
|
+
"token456" => { id: 2, name: "Bob", email: "bob@example.com" }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
def get_current_user
|
|
560
|
+
->(req:) {
|
|
561
|
+
auth = req.env['HTTP_AUTHORIZATION']
|
|
562
|
+
raise AuthError.new unless auth
|
|
563
|
+
|
|
564
|
+
token = auth.split(' ').last
|
|
565
|
+
user = FAKE_DB[token]
|
|
566
|
+
raise AuthError.new("Invalid token") unless user
|
|
567
|
+
|
|
568
|
+
user
|
|
569
|
+
}
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def get_admin_user
|
|
573
|
+
->(user: FunApi::Depends.new(get_current_user)) {
|
|
574
|
+
raise FunApi::HTTPException.new(
|
|
575
|
+
status_code: 403,
|
|
576
|
+
detail: "Not authorized"
|
|
577
|
+
) unless user[:name] == "Alice"
|
|
578
|
+
|
|
579
|
+
user
|
|
580
|
+
}
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
app = FunApi::App.new(
|
|
584
|
+
title: "Dependency Injection Auth Demo",
|
|
585
|
+
version: "1.0.0"
|
|
586
|
+
) do |api|
|
|
587
|
+
api.get '/public' do |input, req, task|
|
|
588
|
+
[{ message: "Public endpoint" }, 200]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
api.get '/profile',
|
|
592
|
+
depends: { user: get_current_user } do |input, req, task, user:|
|
|
593
|
+
[user, 200]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
api.get '/admin',
|
|
597
|
+
depends: { admin: get_admin_user } do |input, req, task, admin:|
|
|
598
|
+
[{ message: "Admin area", user: admin }, 200]
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
puts "Starting server on http://localhost:3000"
|
|
603
|
+
puts "Try:"
|
|
604
|
+
puts " curl http://localhost:3000/public"
|
|
605
|
+
puts " curl -H 'Authorization: Bearer token123' http://localhost:3000/profile"
|
|
606
|
+
puts " curl -H 'Authorization: Bearer token123' http://localhost:3000/admin"
|
|
607
|
+
puts " curl -H 'Authorization: Bearer token456' http://localhost:3000/admin"
|
|
608
|
+
|
|
609
|
+
FunApi::Server::Falcon.start(app, port: 3000)
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Database Connection Example
|
|
613
|
+
|
|
614
|
+
```ruby
|
|
615
|
+
# examples/dependency_database.rb
|
|
616
|
+
class Database
|
|
617
|
+
def self.connect
|
|
618
|
+
new
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def find_user(id)
|
|
622
|
+
{ id: id, name: "User #{id}" }
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def all_users
|
|
626
|
+
[
|
|
627
|
+
{ id: 1, name: "Alice" },
|
|
628
|
+
{ id: 2, name: "Bob" }
|
|
629
|
+
]
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
app = FunApi::App.new do |api|
|
|
634
|
+
db_connection = -> { Database.connect }
|
|
635
|
+
|
|
636
|
+
api.get '/users',
|
|
637
|
+
depends: { db: db_connection } do |input, req, task, db:|
|
|
638
|
+
users = db.all_users
|
|
639
|
+
[users, 200]
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
api.get '/users/:id',
|
|
643
|
+
depends: { db: db_connection } do |input, req, task, db:|
|
|
644
|
+
user = db.find_user(input[:path]['id'].to_i)
|
|
645
|
+
[user, 200]
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### Pagination Example
|
|
651
|
+
|
|
652
|
+
```ruby
|
|
653
|
+
# examples/dependency_pagination.rb
|
|
654
|
+
class Paginator
|
|
655
|
+
def initialize(max_limit: 100)
|
|
656
|
+
@max_limit = max_limit
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def call(input:)
|
|
660
|
+
limit = input[:query][:limit] || 10
|
|
661
|
+
offset = input[:query][:offset] || 0
|
|
662
|
+
|
|
663
|
+
{
|
|
664
|
+
limit: [limit.to_i, @max_limit].min,
|
|
665
|
+
offset: offset.to_i
|
|
666
|
+
}
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
QuerySchema = FunApi::Schema.define do
|
|
671
|
+
optional(:limit).filled(:integer)
|
|
672
|
+
optional(:offset).filled(:integer)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
app = FunApi::App.new do |api|
|
|
676
|
+
pagination = Paginator.new(max_limit: 50)
|
|
677
|
+
|
|
678
|
+
api.get '/items',
|
|
679
|
+
query: QuerySchema,
|
|
680
|
+
depends: { page: pagination } do |input, req, task, page:|
|
|
681
|
+
|
|
682
|
+
items = (1..100).to_a
|
|
683
|
+
paginated = items[page[:offset], page[:limit]]
|
|
684
|
+
|
|
685
|
+
[{
|
|
686
|
+
items: paginated,
|
|
687
|
+
pagination: page,
|
|
688
|
+
total: items.length
|
|
689
|
+
}, 200]
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Implementation Checklist
|
|
695
|
+
|
|
696
|
+
**Phase 1: Core (MVP)**
|
|
697
|
+
- [ ] Create `FunApi::Depends` class
|
|
698
|
+
- [ ] Add `depends:` parameter to `add_route`
|
|
699
|
+
- [ ] Implement basic dependency resolution
|
|
700
|
+
- [ ] Support proc/lambda dependencies
|
|
701
|
+
- [ ] Write core tests
|
|
702
|
+
- [ ] Update AGENTS.md with new patterns
|
|
703
|
+
|
|
704
|
+
**Phase 2: Advanced**
|
|
705
|
+
- [ ] Support class-based dependencies (callable)
|
|
706
|
+
- [ ] Support nested dependencies
|
|
707
|
+
- [ ] Request context injection (req, input, task)
|
|
708
|
+
- [ ] Write advanced tests
|
|
709
|
+
- [ ] Create example: authentication
|
|
710
|
+
- [ ] Create example: database connection
|
|
711
|
+
- [ ] Create example: pagination
|
|
712
|
+
|
|
713
|
+
**Phase 3: Polish**
|
|
714
|
+
- [ ] Error handling for dependency failures
|
|
715
|
+
- [ ] OpenAPI integration (if applicable)
|
|
716
|
+
- [ ] Performance optimization
|
|
717
|
+
- [ ] Documentation in README.md
|
|
718
|
+
- [ ] Add to DECISIONS.md
|
|
719
|
+
|
|
720
|
+
## Open Questions
|
|
721
|
+
|
|
722
|
+
1. **Dependency Caching**: Should dependencies be cached per-request?
|
|
723
|
+
- Probably yes for request-scoped dependencies
|
|
724
|
+
- Need to ensure same dependency called multiple times returns same instance
|
|
725
|
+
|
|
726
|
+
2. **Dependency with `yield`**: Support setup/teardown pattern?
|
|
727
|
+
- Could be useful for database connections
|
|
728
|
+
- Would need to track lifecycle carefully
|
|
729
|
+
|
|
730
|
+
3. **Global Dependencies**: Should we support app-level dependencies?
|
|
731
|
+
- FastAPI has this
|
|
732
|
+
- Could be useful for common auth, logging, etc.
|
|
733
|
+
|
|
734
|
+
4. **Testing Helpers**: How to override dependencies in tests?
|
|
735
|
+
- FastAPI has dependency overrides
|
|
736
|
+
- Could be very useful for testing
|
|
737
|
+
|
|
738
|
+
## Success Criteria
|
|
739
|
+
|
|
740
|
+
1. Clean, Ruby-idiomatic API that feels natural
|
|
741
|
+
2. Supports common use cases: auth, database, pagination
|
|
742
|
+
3. Works seamlessly with existing FunApi features
|
|
743
|
+
4. Well-tested with comprehensive test suite
|
|
744
|
+
5. Clear documentation and examples
|
|
745
|
+
6. No performance degradation for routes without dependencies
|
|
746
|
+
|
|
747
|
+
## Future Enhancements
|
|
748
|
+
|
|
749
|
+
- Dependency overrides for testing
|
|
750
|
+
- Background task dependencies
|
|
751
|
+
- Dependency lifecycle hooks (setup/teardown with yield)
|
|
752
|
+
- Global/application-level dependencies
|
|
753
|
+
- Dependency graph visualization/debugging tools
|