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,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Dependencies
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Dependencies
|
|
6
|
+
|
|
7
|
+
Dependency injection lets you provide services to your handlers without global state.
|
|
8
|
+
|
|
9
|
+
## Registering Dependencies
|
|
10
|
+
|
|
11
|
+
Register dependencies in the app container:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
app = FunApi::App.new do |api|
|
|
15
|
+
api.register(:db) { Database.connect }
|
|
16
|
+
api.register(:logger) { Logger.new(STDOUT) }
|
|
17
|
+
api.register(:mailer) { Mailer.new }
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Using Dependencies
|
|
22
|
+
|
|
23
|
+
Request dependencies with the `depends:` parameter:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
api.get '/users', depends: [:db] do |input, req, task, db:|
|
|
27
|
+
users = db.query("SELECT * FROM users")
|
|
28
|
+
[{ users: users }, 200]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
api.post '/contact', depends: [:mailer, :logger] do |input, req, task, mailer:, logger:|
|
|
32
|
+
logger.info("Sending contact email")
|
|
33
|
+
mailer.send(input[:body])
|
|
34
|
+
[{ sent: true }, 200]
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Dependency Cleanup
|
|
39
|
+
|
|
40
|
+
For resources that need cleanup (database connections, file handles), return a tuple:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
api.register(:db) do
|
|
44
|
+
conn = Database.connect
|
|
45
|
+
cleanup = -> { conn.close }
|
|
46
|
+
[conn, cleanup]
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The cleanup proc runs after the request completes.
|
|
51
|
+
|
|
52
|
+
## Block-Style Dependencies
|
|
53
|
+
|
|
54
|
+
For context-manager style cleanup (like Python's `with`):
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
api.register(:transaction) do |yielder|
|
|
58
|
+
db = Database.connect
|
|
59
|
+
db.begin_transaction
|
|
60
|
+
|
|
61
|
+
yielder.call(db) # Yield the resource
|
|
62
|
+
|
|
63
|
+
db.commit
|
|
64
|
+
rescue
|
|
65
|
+
db.rollback
|
|
66
|
+
raise
|
|
67
|
+
ensure
|
|
68
|
+
db.close
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Per-Request Dependencies
|
|
73
|
+
|
|
74
|
+
Dependencies can access request context:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
api.register(:current_user) do
|
|
78
|
+
# This runs fresh for each request
|
|
79
|
+
User.find_by_token(request.headers['Authorization'])
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Depends Class
|
|
84
|
+
|
|
85
|
+
For complex dependency graphs, use `FunApi::Depends`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
get_db = -> { Database.connect }
|
|
89
|
+
get_user = ->(db:) { db.find_user(current_token) }
|
|
90
|
+
|
|
91
|
+
api.get '/profile', depends: {
|
|
92
|
+
db: get_db,
|
|
93
|
+
user: FunApi.Depends(get_user, db: :db)
|
|
94
|
+
} do |input, req, task, db:, user:|
|
|
95
|
+
[{ user: user }, 200]
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Complete Example
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
require 'funapi'
|
|
103
|
+
require 'funapi/server/falcon'
|
|
104
|
+
|
|
105
|
+
app = FunApi::App.new(title: "My API") do |api|
|
|
106
|
+
# Simple dependency
|
|
107
|
+
api.register(:logger) { Logger.new(STDOUT) }
|
|
108
|
+
|
|
109
|
+
# Dependency with cleanup
|
|
110
|
+
api.register(:db) do
|
|
111
|
+
conn = PG.connect(ENV['DATABASE_URL'])
|
|
112
|
+
[conn, -> { conn.close }]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Block-style dependency
|
|
116
|
+
api.register(:transaction) do |yielder|
|
|
117
|
+
conn = PG.connect(ENV['DATABASE_URL'])
|
|
118
|
+
conn.exec("BEGIN")
|
|
119
|
+
yielder.call(conn)
|
|
120
|
+
conn.exec("COMMIT")
|
|
121
|
+
rescue
|
|
122
|
+
conn.exec("ROLLBACK")
|
|
123
|
+
raise
|
|
124
|
+
ensure
|
|
125
|
+
conn.close
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
api.get '/users', depends: [:db, :logger] do |input, req, task, db:, logger:|
|
|
129
|
+
logger.info("Fetching users")
|
|
130
|
+
result = db.exec("SELECT * FROM users")
|
|
131
|
+
[{ users: result.to_a }, 200]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
api.post '/users', depends: [:transaction] do |input, req, task, transaction:|
|
|
135
|
+
transaction.exec("INSERT INTO users (name) VALUES ($1)", [input[:body][:name]])
|
|
136
|
+
[{ created: true }, 201]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
FunApi::Server::Falcon.start(app, port: 3000)
|
|
141
|
+
```
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Deployment
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Deployment
|
|
6
|
+
|
|
7
|
+
Deploy FunApi applications to production.
|
|
8
|
+
|
|
9
|
+
## Running with Falcon
|
|
10
|
+
|
|
11
|
+
FunApi uses Falcon as its server:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# app.rb
|
|
15
|
+
require 'funapi'
|
|
16
|
+
require 'funapi/server/falcon'
|
|
17
|
+
|
|
18
|
+
app = FunApi::App.new do |api|
|
|
19
|
+
# routes...
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
FunApi::Server::Falcon.start(app,
|
|
23
|
+
host: '0.0.0.0',
|
|
24
|
+
port: ENV.fetch('PORT', 3000).to_i
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ruby app.rb
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Environment Variables
|
|
33
|
+
|
|
34
|
+
Configure your app with environment variables:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
app = FunApi::App.new do |api|
|
|
38
|
+
api.on_startup do
|
|
39
|
+
DB.connect(ENV.fetch('DATABASE_URL'))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if ENV['RACK_ENV'] == 'production'
|
|
43
|
+
api.add_trusted_host(allowed_hosts: [ENV['HOST']])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Docker
|
|
49
|
+
|
|
50
|
+
### Dockerfile
|
|
51
|
+
|
|
52
|
+
```dockerfile
|
|
53
|
+
FROM ruby:3.2-alpine
|
|
54
|
+
|
|
55
|
+
RUN apk add --no-cache build-base
|
|
56
|
+
|
|
57
|
+
WORKDIR /app
|
|
58
|
+
|
|
59
|
+
COPY Gemfile Gemfile.lock ./
|
|
60
|
+
RUN bundle install --without development test
|
|
61
|
+
|
|
62
|
+
COPY . .
|
|
63
|
+
|
|
64
|
+
EXPOSE 3000
|
|
65
|
+
|
|
66
|
+
CMD ["ruby", "app.rb"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### docker-compose.yml
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
version: '3.8'
|
|
73
|
+
|
|
74
|
+
services:
|
|
75
|
+
web:
|
|
76
|
+
build: .
|
|
77
|
+
ports:
|
|
78
|
+
- "3000:3000"
|
|
79
|
+
environment:
|
|
80
|
+
- DATABASE_URL=postgres://postgres:password@db/myapp
|
|
81
|
+
- RACK_ENV=production
|
|
82
|
+
depends_on:
|
|
83
|
+
- db
|
|
84
|
+
|
|
85
|
+
db:
|
|
86
|
+
image: postgres:15
|
|
87
|
+
environment:
|
|
88
|
+
- POSTGRES_PASSWORD=password
|
|
89
|
+
- POSTGRES_DB=myapp
|
|
90
|
+
volumes:
|
|
91
|
+
- postgres_data:/var/lib/postgresql/data
|
|
92
|
+
|
|
93
|
+
volumes:
|
|
94
|
+
postgres_data:
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Fly.io
|
|
98
|
+
|
|
99
|
+
### fly.toml
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
app = "my-funapi-app"
|
|
103
|
+
primary_region = "iad"
|
|
104
|
+
|
|
105
|
+
[build]
|
|
106
|
+
builder = "heroku/buildpacks:22"
|
|
107
|
+
|
|
108
|
+
[env]
|
|
109
|
+
RACK_ENV = "production"
|
|
110
|
+
|
|
111
|
+
[http_service]
|
|
112
|
+
internal_port = 3000
|
|
113
|
+
force_https = true
|
|
114
|
+
auto_stop_machines = true
|
|
115
|
+
auto_start_machines = true
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Deploy:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
fly launch
|
|
122
|
+
fly deploy
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Render
|
|
126
|
+
|
|
127
|
+
Create a `render.yaml`:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
services:
|
|
131
|
+
- type: web
|
|
132
|
+
name: my-funapi-app
|
|
133
|
+
env: ruby
|
|
134
|
+
buildCommand: bundle install
|
|
135
|
+
startCommand: ruby app.rb
|
|
136
|
+
envVars:
|
|
137
|
+
- key: RACK_ENV
|
|
138
|
+
value: production
|
|
139
|
+
- key: DATABASE_URL
|
|
140
|
+
fromDatabase:
|
|
141
|
+
name: mydb
|
|
142
|
+
property: connectionString
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Production Checklist
|
|
146
|
+
|
|
147
|
+
### Security
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
app = FunApi::App.new do |api|
|
|
151
|
+
# Validate host header
|
|
152
|
+
api.add_trusted_host(allowed_hosts: ['myapp.com', 'api.myapp.com'])
|
|
153
|
+
|
|
154
|
+
# CORS with specific origins
|
|
155
|
+
api.add_cors(allow_origins: ['https://myapp.com'])
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Logging
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
api.add_request_logger(
|
|
163
|
+
logger: Logger.new(STDOUT),
|
|
164
|
+
level: :info
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Error Handling
|
|
169
|
+
|
|
170
|
+
Don't expose internal errors in production:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class ProductionErrorHandler
|
|
174
|
+
def initialize(app)
|
|
175
|
+
@app = app
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def call(env)
|
|
179
|
+
@app.call(env)
|
|
180
|
+
rescue => e
|
|
181
|
+
# Log the real error
|
|
182
|
+
Logger.new(STDOUT).error("#{e.class}: #{e.message}")
|
|
183
|
+
|
|
184
|
+
# Return generic message
|
|
185
|
+
[500, {'content-type' => 'application/json'},
|
|
186
|
+
['{"detail":"Internal server error"}']]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
app = FunApi::App.new do |api|
|
|
191
|
+
api.use ProductionErrorHandler if ENV['RACK_ENV'] == 'production'
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Health Check
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
api.get '/health' do |input, req, task|
|
|
199
|
+
[{ status: 'ok', time: Time.now.iso8601 }, 200]
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Graceful Shutdown
|
|
204
|
+
|
|
205
|
+
FunApi handles SIGINT and SIGTERM automatically, running shutdown hooks before exiting.
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
api.on_shutdown do
|
|
209
|
+
puts "Draining connections..."
|
|
210
|
+
ConnectionPool.shutdown
|
|
211
|
+
end
|
|
212
|
+
```
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Error Handling
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Error Handling
|
|
6
|
+
|
|
7
|
+
FunApi provides FastAPI-style error responses.
|
|
8
|
+
|
|
9
|
+
## HTTPException
|
|
10
|
+
|
|
11
|
+
Raise `HTTPException` to return an error response:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
api.get '/users/:id' do |input, req, task|
|
|
15
|
+
user = find_user(input[:path]['id'])
|
|
16
|
+
|
|
17
|
+
unless user
|
|
18
|
+
raise FunApi::HTTPException.new(
|
|
19
|
+
status_code: 404,
|
|
20
|
+
detail: "User not found"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
[{ user: user }, 200]
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Response:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"detail": "User not found"
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Common Status Codes
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# 400 Bad Request
|
|
40
|
+
raise FunApi::HTTPException.new(status_code: 400, detail: "Invalid input")
|
|
41
|
+
|
|
42
|
+
# 401 Unauthorized
|
|
43
|
+
raise FunApi::HTTPException.new(status_code: 401, detail: "Authentication required")
|
|
44
|
+
|
|
45
|
+
# 403 Forbidden
|
|
46
|
+
raise FunApi::HTTPException.new(status_code: 403, detail: "Permission denied")
|
|
47
|
+
|
|
48
|
+
# 404 Not Found
|
|
49
|
+
raise FunApi::HTTPException.new(status_code: 404, detail: "Resource not found")
|
|
50
|
+
|
|
51
|
+
# 409 Conflict
|
|
52
|
+
raise FunApi::HTTPException.new(status_code: 409, detail: "Already exists")
|
|
53
|
+
|
|
54
|
+
# 422 Unprocessable Entity
|
|
55
|
+
raise FunApi::HTTPException.new(status_code: 422, detail: "Validation failed")
|
|
56
|
+
|
|
57
|
+
# 500 Internal Server Error
|
|
58
|
+
raise FunApi::HTTPException.new(status_code: 500, detail: "Something went wrong")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Custom Headers
|
|
62
|
+
|
|
63
|
+
Add headers to error responses:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
raise FunApi::HTTPException.new(
|
|
67
|
+
status_code: 401,
|
|
68
|
+
detail: "Token expired",
|
|
69
|
+
headers: { 'WWW-Authenticate' => 'Bearer' }
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## ValidationError
|
|
74
|
+
|
|
75
|
+
`ValidationError` is raised automatically by schema validation, but you can raise it manually:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
raise FunApi::ValidationError.new(
|
|
79
|
+
errors: [
|
|
80
|
+
{ path: [:email], text: "is invalid" }
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Response (422):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"detail": [
|
|
90
|
+
{
|
|
91
|
+
"loc": ["email"],
|
|
92
|
+
"msg": "is invalid",
|
|
93
|
+
"type": "value_error"
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Handling Exceptions in Handlers
|
|
100
|
+
|
|
101
|
+
Use standard Ruby exception handling:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
api.get '/external' do |input, req, task|
|
|
105
|
+
begin
|
|
106
|
+
data = ExternalAPI.fetch
|
|
107
|
+
[{ data: data }, 200]
|
|
108
|
+
rescue ExternalAPI::Timeout
|
|
109
|
+
raise FunApi::HTTPException.new(
|
|
110
|
+
status_code: 504,
|
|
111
|
+
detail: "External service timeout"
|
|
112
|
+
)
|
|
113
|
+
rescue ExternalAPI::Error => e
|
|
114
|
+
raise FunApi::HTTPException.new(
|
|
115
|
+
status_code: 502,
|
|
116
|
+
detail: "External service error: #{e.message}"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Custom Error Classes
|
|
123
|
+
|
|
124
|
+
Create domain-specific errors:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class NotFoundError < FunApi::HTTPException
|
|
128
|
+
def initialize(resource, id)
|
|
129
|
+
super(
|
|
130
|
+
status_code: 404,
|
|
131
|
+
detail: "#{resource} with id #{id} not found"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class UnauthorizedError < FunApi::HTTPException
|
|
137
|
+
def initialize(message = "Authentication required")
|
|
138
|
+
super(
|
|
139
|
+
status_code: 401,
|
|
140
|
+
detail: message,
|
|
141
|
+
headers: { 'WWW-Authenticate' => 'Bearer' }
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Usage
|
|
147
|
+
api.get '/users/:id' do |input, req, task|
|
|
148
|
+
user = find_user(input[:path]['id'])
|
|
149
|
+
raise NotFoundError.new('User', input[:path]['id']) unless user
|
|
150
|
+
[{ user: user }, 200]
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Error Middleware
|
|
155
|
+
|
|
156
|
+
Handle errors globally with middleware:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class ErrorHandlerMiddleware
|
|
160
|
+
def initialize(app)
|
|
161
|
+
@app = app
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def call(env)
|
|
165
|
+
@app.call(env)
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
# Log the error
|
|
168
|
+
puts "Error: #{e.message}"
|
|
169
|
+
puts e.backtrace.first(5).join("\n")
|
|
170
|
+
|
|
171
|
+
# Return generic error in production
|
|
172
|
+
[
|
|
173
|
+
500,
|
|
174
|
+
{ 'content-type' => 'application/json' },
|
|
175
|
+
[JSON.dump(detail: 'Internal server error')]
|
|
176
|
+
]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
app = FunApi::App.new do |api|
|
|
181
|
+
api.use ErrorHandlerMiddleware
|
|
182
|
+
# routes...
|
|
183
|
+
end
|
|
184
|
+
```
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Response Schema
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Response Schema
|
|
6
|
+
|
|
7
|
+
Filter and validate response data before sending to clients.
|
|
8
|
+
|
|
9
|
+
## Why Response Schemas?
|
|
10
|
+
|
|
11
|
+
Response schemas help you:
|
|
12
|
+
|
|
13
|
+
1. **Filter sensitive data** - Remove passwords, tokens, internal IDs
|
|
14
|
+
2. **Validate output** - Catch bugs before they reach clients
|
|
15
|
+
3. **Document responses** - Auto-generate OpenAPI response schemas
|
|
16
|
+
|
|
17
|
+
## Basic Usage
|
|
18
|
+
|
|
19
|
+
Define a schema for the response:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
UserOutputSchema = FunApi::Schema.define do
|
|
23
|
+
required(:id).filled(:integer)
|
|
24
|
+
required(:name).filled(:string)
|
|
25
|
+
required(:email).filled(:string)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
api.get '/users/:id', response_schema: UserOutputSchema do |input, req, task|
|
|
29
|
+
user = find_user(input[:path]['id'])
|
|
30
|
+
# Even if user has :password, :api_key, etc., they're filtered out
|
|
31
|
+
[user, 200]
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Filtering Sensitive Data
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# Internal user record
|
|
39
|
+
user = {
|
|
40
|
+
id: 1,
|
|
41
|
+
name: "Alice",
|
|
42
|
+
email: "alice@example.com",
|
|
43
|
+
password_hash: "abc123...",
|
|
44
|
+
api_key: "secret...",
|
|
45
|
+
internal_notes: "VIP customer"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# With response_schema: UserOutputSchema
|
|
49
|
+
# Client receives only:
|
|
50
|
+
{
|
|
51
|
+
"id": 1,
|
|
52
|
+
"name": "Alice",
|
|
53
|
+
"email": "alice@example.com"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Array Responses
|
|
58
|
+
|
|
59
|
+
For array responses, wrap the schema in brackets:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
api.get '/users', response_schema: [UserOutputSchema] do |input, req, task|
|
|
63
|
+
users = fetch_all_users
|
|
64
|
+
[users, 200]
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Different Input/Output Schemas
|
|
69
|
+
|
|
70
|
+
Common pattern: accept more fields than you return.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
UserCreateSchema = FunApi::Schema.define do
|
|
74
|
+
required(:name).filled(:string)
|
|
75
|
+
required(:email).filled(:string)
|
|
76
|
+
required(:password).filled(:string)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
UserOutputSchema = FunApi::Schema.define do
|
|
80
|
+
required(:id).filled(:integer)
|
|
81
|
+
required(:name).filled(:string)
|
|
82
|
+
required(:email).filled(:string)
|
|
83
|
+
required(:created_at).filled(:string)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
api.post '/users',
|
|
87
|
+
body: UserCreateSchema,
|
|
88
|
+
response_schema: UserOutputSchema do |input, req, task|
|
|
89
|
+
|
|
90
|
+
user = create_user(input[:body])
|
|
91
|
+
# password is filtered out of response
|
|
92
|
+
[user, 201]
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Nested Objects
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
AddressSchema = FunApi::Schema.define do
|
|
100
|
+
required(:city).filled(:string)
|
|
101
|
+
required(:country).filled(:string)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
UserWithAddressSchema = FunApi::Schema.define do
|
|
105
|
+
required(:id).filled(:integer)
|
|
106
|
+
required(:name).filled(:string)
|
|
107
|
+
required(:address).hash do
|
|
108
|
+
required(:city).filled(:string)
|
|
109
|
+
required(:country).filled(:string)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
api.get '/users/:id', response_schema: UserWithAddressSchema do |input, req, task|
|
|
114
|
+
user = find_user_with_address(input[:path]['id'])
|
|
115
|
+
[user, 200]
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Validation Errors
|
|
120
|
+
|
|
121
|
+
If your response doesn't match the schema, FunApi returns a 500 error:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
api.get '/broken', response_schema: UserOutputSchema do |input, req, task|
|
|
125
|
+
# Missing required :email field
|
|
126
|
+
[{ id: 1, name: "Alice" }, 200]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Response: 500
|
|
130
|
+
# {"detail":"Response validation failed: {:email=>[\"is missing\"]}"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This helps catch bugs in development before they reach production.
|
|
134
|
+
|
|
135
|
+
## OpenAPI Integration
|
|
136
|
+
|
|
137
|
+
Response schemas appear in your OpenAPI documentation:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"paths": {
|
|
142
|
+
"/users/{id}": {
|
|
143
|
+
"get": {
|
|
144
|
+
"responses": {
|
|
145
|
+
"200": {
|
|
146
|
+
"content": {
|
|
147
|
+
"application/json": {
|
|
148
|
+
"schema": {
|
|
149
|
+
"$ref": "#/components/schemas/UserOutputSchema"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|