steppe 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/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +883 -0
- data/Rakefile +23 -0
- data/docs/README.md +3 -0
- data/docs/styles.css +527 -0
- data/examples/hanami.ru +29 -0
- data/examples/service.rb +323 -0
- data/examples/sinatra.rb +38 -0
- data/lib/docs_builder.rb +253 -0
- data/lib/steppe/auth/basic.rb +130 -0
- data/lib/steppe/auth/bearer.rb +130 -0
- data/lib/steppe/auth.rb +46 -0
- data/lib/steppe/content_type.rb +80 -0
- data/lib/steppe/endpoint.rb +742 -0
- data/lib/steppe/openapi_visitor.rb +155 -0
- data/lib/steppe/request.rb +22 -0
- data/lib/steppe/responder.rb +165 -0
- data/lib/steppe/responder_registry.rb +79 -0
- data/lib/steppe/result.rb +68 -0
- data/lib/steppe/serializer.rb +180 -0
- data/lib/steppe/service.rb +232 -0
- data/lib/steppe/status_map.rb +82 -0
- data/lib/steppe/utils.rb +19 -0
- data/lib/steppe/version.rb +5 -0
- data/lib/steppe.rb +44 -0
- data/sig/steppe.rbs +4 -0
- metadata +143 -0
data/README.md
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
# Steppe - Composable, self-documenting REST APIs for Ruby
|
|
2
|
+
|
|
3
|
+
Steppe is a Ruby gem that provides a DSL for building REST APIs with an emphasis on:
|
|
4
|
+
|
|
5
|
+
* Composability - Built on composable pipelines, allowing endpoints to be assembled from reusable, testable validation and processing
|
|
6
|
+
steps
|
|
7
|
+
* Type Safety & Validation - Define input schemas for query parameters and request bodies, ensuring data is
|
|
8
|
+
validated and coerced before reaching business logic
|
|
9
|
+
* Expandable API - start with a terse DSL for defining endpoints, and extend with custom steps as needed.
|
|
10
|
+
* Self-Documentation - Automatically generates OpenAPI specifications from endpoint definitions, keeping documentation in sync with
|
|
11
|
+
implementation
|
|
12
|
+
* Content Negotiation - Handles multiple response formats through a Responder system that matches status codes and content types
|
|
13
|
+
* Mountable on Rack routers, and (soon) standalone with its own router.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Defining a service
|
|
18
|
+
|
|
19
|
+
A Service is a container for API endpoints with metadata:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require 'steppe'
|
|
23
|
+
|
|
24
|
+
Service = Steppe::Service.new do |api|
|
|
25
|
+
api.title = 'Users API'
|
|
26
|
+
api.description = 'API for managing users'
|
|
27
|
+
api.server(
|
|
28
|
+
url: 'http://localhost:4567',
|
|
29
|
+
description: 'Production server'
|
|
30
|
+
)
|
|
31
|
+
api.tag('users', description: 'User management operations')
|
|
32
|
+
|
|
33
|
+
# Define endpoints here...
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Defining endpoints
|
|
38
|
+
|
|
39
|
+
Endpoints define HTTP routes with validation, processing steps, and response serialization:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# GET endpoint with query parameter validation
|
|
43
|
+
api.get :users, '/users' do |e|
|
|
44
|
+
e.description = 'List users'
|
|
45
|
+
e.tags = %w[users]
|
|
46
|
+
|
|
47
|
+
# Validate query parameters
|
|
48
|
+
e.query_schema(
|
|
49
|
+
q?: Types::String.desc('Search by name'),
|
|
50
|
+
limit?: Types::Lax::Integer.default(10).desc('Number of results')
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Business logic step
|
|
54
|
+
e.step do |conn|
|
|
55
|
+
users = User.filter_by_name(conn.params[:q])
|
|
56
|
+
.limit(conn.params[:limit])
|
|
57
|
+
conn.valid users
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# JSON response serialization
|
|
61
|
+
e.json do
|
|
62
|
+
attribute :users, [UserSerializer]
|
|
63
|
+
|
|
64
|
+
def users
|
|
65
|
+
object
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Query schemas
|
|
72
|
+
|
|
73
|
+
Use `#query_schema` to register steps to coerce and validate URL path and query parameters.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
api.get :list_users, '/users' do |e|
|
|
77
|
+
e.description = 'List and filter users'
|
|
78
|
+
# URL path and query parameters will be passed through this schema
|
|
79
|
+
# You can annotate fields with .desc() and .example() to supplement
|
|
80
|
+
# the generated OpenAPI specs
|
|
81
|
+
e.query_schema(
|
|
82
|
+
q?: Types::String.desc('full text search').example('bo, media'),
|
|
83
|
+
status?: Types::String.desc('status filter').options(%w[active inactive])
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# coerced and validated parameters are now
|
|
87
|
+
# available in conn.params
|
|
88
|
+
e.step do |conn|
|
|
89
|
+
users = User
|
|
90
|
+
users = users.search(conn.params[:q]) if conn.params[:q]
|
|
91
|
+
users = users.by_status(conn.params[:status]) if conn.params[:status]
|
|
92
|
+
conn.valid users
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# GET /users?status=active&q=bob
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### Path parameters
|
|
100
|
+
|
|
101
|
+
URL path parameters are automatically extracted and merged into a default query schema:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# the presence of path tokens in path, such as :id
|
|
105
|
+
# will automatically register #query_schema(id: Types::String)
|
|
106
|
+
api.get :user, '/users/:id' do |e|
|
|
107
|
+
e.description = 'Fetch a user by ID'
|
|
108
|
+
e.step do |conn|
|
|
109
|
+
# conn.params[:id] is a string
|
|
110
|
+
user = User.find(conn.params[:id])
|
|
111
|
+
user ? conn.valid(user) : conn.invalid(errors: { id: 'Not found' })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
e.json 200...300, UserSerializer
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You can extend the implicit query schema to add or update individual fields
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Override the implicit :id field
|
|
122
|
+
# to coerce it to an integer
|
|
123
|
+
e.query_schema(
|
|
124
|
+
id: Types::Lax::Integer
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
e.step do |conn|
|
|
128
|
+
# conn.params[:id] is an Integer
|
|
129
|
+
conn.valid conn.params[:id] * 10
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Multiple calls to `#query_schema` will aggregate into a single `Endpoint#query_schema`
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
UsersAPI[:user].query_schema # => Plumb::Types::Hash
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Payload schemas
|
|
140
|
+
|
|
141
|
+
Use `payload_schema` to validate request bodies:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
api.post :create_user, '/users' do |e|
|
|
145
|
+
e.description = 'Create a user'
|
|
146
|
+
e.tags = %w[users]
|
|
147
|
+
|
|
148
|
+
# Validate request body
|
|
149
|
+
e.payload_schema(
|
|
150
|
+
user: {
|
|
151
|
+
name: Types::String.desc('User name').example('Alice'),
|
|
152
|
+
email: Types::Email.desc('User email').example('alice@example.com'),
|
|
153
|
+
age: Types::Lax::Integer.desc('User age').example(30)
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Create user (only runs if payload is valid)
|
|
158
|
+
e.step do |conn|
|
|
159
|
+
user = User.create(conn.params[:user])
|
|
160
|
+
conn.respond_with(201).valid user
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Serialize response
|
|
164
|
+
e.json 201, UserSerializer
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### It's pipelines steps all the way down
|
|
169
|
+
|
|
170
|
+
Query and payload schemas are themselves steps in the processing pipeline, so you can insert steps before or after each of them.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
# Coerce and validate query parameters
|
|
174
|
+
e.query_schema(
|
|
175
|
+
id: Types::Lax::Integer.present
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Use (validated, coerced) ID to locate resource
|
|
179
|
+
# and do some custom authorization
|
|
180
|
+
e.step do |conn|
|
|
181
|
+
user = User.find(conn.params[:id])
|
|
182
|
+
if user.can_update_account?
|
|
183
|
+
conn.continue user
|
|
184
|
+
else
|
|
185
|
+
conn.respond_with(401).halt
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Only NOW parse and validate request body
|
|
190
|
+
e.payload_schema(
|
|
191
|
+
name: Types::String.present.desc('Account name'),
|
|
192
|
+
email: Types::Email.presen.desc('Account email')
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
A "step" is an `#call(Steppe::Result) => Steppe::Result` interface. You can use procs, or you can use your own objects.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class FindAndAuthorizeUser
|
|
200
|
+
def self.call(conn)
|
|
201
|
+
user = User.find(conn.params[:id])
|
|
202
|
+
return conn.respond_with(401).halt unless user.can_update_account?
|
|
203
|
+
|
|
204
|
+
conn.continue(user)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# In your endpoint
|
|
209
|
+
e.step FindAndAuthorizeUser
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
It's up to you how/if your custom steps manage their own state (ie. classes vs. instances). You can use instances for configuration, for example.
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
# Works as long as the instance responds to #call(Result) => Result
|
|
216
|
+
e.step MyCustomAuthorizer.new(role: 'admin')
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### Halting the pipeline
|
|
220
|
+
|
|
221
|
+
A step that returns a `Continue` result passes the result on to the next step.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
e.step do |conn|
|
|
225
|
+
conn.continue('hello')
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
A step that returns a `Halt` result signals the pipeline to stop processing.
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# This step halts the pipeline
|
|
233
|
+
e.step do |conn|
|
|
234
|
+
conn.halt
|
|
235
|
+
# Or
|
|
236
|
+
# conn.invalid(errors: {name: 'is invalid'})
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# This step will never run
|
|
240
|
+
e.step do |conn|
|
|
241
|
+
# etc
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### Steps with schemas
|
|
246
|
+
|
|
247
|
+
A custom step that also supports `#query_schema`, `#payload_schema` and `#header_schema` will have those schemas merged into the endpoint's schemas, which can be used to generate OpenAPI documentation.
|
|
248
|
+
|
|
249
|
+
This is so that you're free to bring your own domain objects that do their own validation.
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
class CreateUser
|
|
253
|
+
def self.payload_schema = Types::Hash[name: String, age: Types::Integer[18..]]
|
|
254
|
+
|
|
255
|
+
def self.call(conn)
|
|
256
|
+
# Instantiate, manage state, run your domain logic, etc
|
|
257
|
+
conn
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# CreateUser.payload_schema will be merged into the endpoint's own payload_schema
|
|
262
|
+
e.step CreateUser
|
|
263
|
+
|
|
264
|
+
# You can add fields to the payload schema
|
|
265
|
+
# The top-level endpoint schema will be the merge of both
|
|
266
|
+
e.payload_schema(
|
|
267
|
+
email: Types::Email.present
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### File Uploads
|
|
272
|
+
|
|
273
|
+
Handle file uploads with the `UploadedFile` type:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
api.post :upload, '/files' do |e|
|
|
277
|
+
e.payload_schema(
|
|
278
|
+
file: Steppe::Types::UploadedFile.with(type: 'text/plain')
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
e.step do |conn|
|
|
282
|
+
file = conn.params[:file]
|
|
283
|
+
# file.tempfile, file.filename, file.type available
|
|
284
|
+
conn.valid(process_file(file))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
e.json 201, FileSerializer
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Named Serializers
|
|
292
|
+
|
|
293
|
+
Define reusable serializers:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class UserSerializer < Steppe::Serializer
|
|
297
|
+
attribute :id, Types::Integer.example(1)
|
|
298
|
+
attribute :name, Types::String.example('Alice')
|
|
299
|
+
attribute :email, Types::Email.example('alice@example.com')
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Use in endpoints
|
|
303
|
+
e.json 200, UserSerializer
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
You can also compose serializers together:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
class UserListSerializer < Steppe::Serializer
|
|
310
|
+
attribute :page, Types::Integer.example(1)
|
|
311
|
+
attribute :users, [UserSerializer]
|
|
312
|
+
|
|
313
|
+
def page = conn.params[:page] || 1
|
|
314
|
+
def users = object
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Serializers are based on [Plumb's Data structs](PostSerializer).
|
|
319
|
+
|
|
320
|
+
### Multiple Response Formats
|
|
321
|
+
|
|
322
|
+
Support multiple content types:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
api.get :user, '/users/:id' do |e|
|
|
326
|
+
e.step { |conn| conn.valid(User.find(conn.params[:id])) }
|
|
327
|
+
|
|
328
|
+
# JSON response
|
|
329
|
+
e.json 200, UserSerializer
|
|
330
|
+
|
|
331
|
+
# HTML response (using Papercraft)
|
|
332
|
+
e.html do |conn|
|
|
333
|
+
html5 {
|
|
334
|
+
body {
|
|
335
|
+
h1 conn.value.name
|
|
336
|
+
p "Email: #{conn.value.email}"
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### HTML templates
|
|
344
|
+
|
|
345
|
+
HTML templates rely on [Papercraft](https://papercraft.noteflakes.com). It's possible to register your own templating though.
|
|
346
|
+
|
|
347
|
+
You can pass inline templates like in the example above, or named constants pointing to HTML components.
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
# Somewhere in your app:
|
|
351
|
+
UserTemplate = proc do |conn|
|
|
352
|
+
html5 {
|
|
353
|
+
body {
|
|
354
|
+
h1 conn.value.name
|
|
355
|
+
p "Email: #{conn.value.email}"
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# In your endpoint
|
|
361
|
+
e.html(200..299, UserTemplate)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
See Papercraft's documentation to learn how to work with [layouts](https://papercraft.noteflakes.com/docs/03-template-composition/02-working-with-layouts), nested [components](https://papercraft.noteflakes.com/docs/03-template-composition/01-component-templates), and more.
|
|
365
|
+
|
|
366
|
+
### Reusable Action Classes
|
|
367
|
+
|
|
368
|
+
Encapsulate logic in action classes:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
class UpdateUser
|
|
372
|
+
SCHEMA = Types::Hash[
|
|
373
|
+
name: Types::String.present,
|
|
374
|
+
age: Types::Lax::Integer[18..]
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
def self.payload_schema = SCHEMA
|
|
378
|
+
|
|
379
|
+
def self.call(conn)
|
|
380
|
+
user = User.update(conn.params[:id], conn.params)
|
|
381
|
+
conn.valid user
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Use in endpoint
|
|
386
|
+
api.put :update_user, '/users/:id' do |e|
|
|
387
|
+
e.step UpdateUser
|
|
388
|
+
e.json 200, UserSerializer
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### OpenAPI Documentation
|
|
393
|
+
|
|
394
|
+
Use a service's `#specs` helper to mount a GET route to automatically serve OpenAPI schemas from.
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
MyAPI = Steppe::Service.new do |api|
|
|
398
|
+
api.title = 'Users API'
|
|
399
|
+
api.description = 'API for managing users'
|
|
400
|
+
|
|
401
|
+
# OpenAPI JSON schemas for this service
|
|
402
|
+
# will be available at GET /schemas (defaults to /)
|
|
403
|
+
api.specs('/schemas')
|
|
404
|
+
|
|
405
|
+
# Define API endpoints
|
|
406
|
+
api.get :list_users, '/users' do |e|
|
|
407
|
+
# etc
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Or use the `OpenAPIVisitor` directly
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# Get OpenAPI JSON
|
|
416
|
+
openapi_spec = Steppe::OpenAPIVisitor.from_request(MyAPI, rack_request)
|
|
417
|
+
|
|
418
|
+
# Or generate manually
|
|
419
|
+
openapi_spec = Steppe::OpenAPIVisitor.call(MyAPI)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
<img width="831" height="855" alt="CleanShot 2025-10-06 at 18 04 55" src="https://github.com/user-attachments/assets/fea61225-538b-4653-bdd0-9f8b21c8c389" />
|
|
423
|
+
Using the [Swagger UI](https://swagger.io/tools/swagger-ui/) tool to view a Steppe API definition.
|
|
424
|
+
|
|
425
|
+
### Mount in Rack-compliant routers
|
|
426
|
+
|
|
427
|
+
#### Sinatra
|
|
428
|
+
|
|
429
|
+
Mount Steppe services in a Sinatra app:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
require 'sinatra/base'
|
|
433
|
+
|
|
434
|
+
class App < Sinatra::Base
|
|
435
|
+
MyService.endpoints.each do |endpoint|
|
|
436
|
+
public_send(endpoint.verb, endpoint.path.to_templates.first) do
|
|
437
|
+
resp = endpoint.run(request).response
|
|
438
|
+
resp.finish
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### `Hanami::Router`
|
|
445
|
+
|
|
446
|
+
The excellent and fast [Hanami::Router]() can be used as a standalone router for Steppe services. Or you can mount them into an existing Hanami app.
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
# hanami_service.ru
|
|
450
|
+
# run with
|
|
451
|
+
# bundle exec rackup ./hanami_service.ru
|
|
452
|
+
require 'hanami/router'
|
|
453
|
+
require 'rack/cors'
|
|
454
|
+
|
|
455
|
+
app = MyService.route_with(Hanami::Router.new)
|
|
456
|
+
|
|
457
|
+
# Or mount within a router block
|
|
458
|
+
app = Hanami::Router.new do
|
|
459
|
+
scope '/api' do
|
|
460
|
+
MyService.route_with(self)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Allowing all origins
|
|
465
|
+
# to make Swagger UI work
|
|
466
|
+
use Rack::Cors do
|
|
467
|
+
allow do
|
|
468
|
+
origins '*'
|
|
469
|
+
resource '*', headers: :any, methods: :any
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
run app
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
See `examples/hanami.ru`
|
|
477
|
+
|
|
478
|
+
### Custom Types
|
|
479
|
+
|
|
480
|
+
Define custom validation types using [Plumb](https://github.com/ismasan/plumb):
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
module Types
|
|
484
|
+
include Plumb::Types
|
|
485
|
+
|
|
486
|
+
UserCategory = String
|
|
487
|
+
.options(%w[admin customer guest])
|
|
488
|
+
.default('guest')
|
|
489
|
+
.desc('User category')
|
|
490
|
+
|
|
491
|
+
DowncaseString = String.invoke(:downcase)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Use in schemas
|
|
495
|
+
e.query_schema(
|
|
496
|
+
category?: Types::UserCategory
|
|
497
|
+
)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Error Handling
|
|
501
|
+
|
|
502
|
+
Endpoints automatically handle validation errors with 422 responses. Customize error responses:
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
e.json 422 do
|
|
506
|
+
attribute :errors, Types::Hash
|
|
507
|
+
|
|
508
|
+
def errors
|
|
509
|
+
object
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Content negotiation
|
|
515
|
+
|
|
516
|
+
The `#json` and `#html` Endpoint methods are shortcuts for `Responder` objects that can be tailored to specific combinations of request accepted content types, and response status.
|
|
517
|
+
|
|
518
|
+
```ruby
|
|
519
|
+
# equivalent to e.json(200, UserSerializer)
|
|
520
|
+
e.respond 200, :json do |r|
|
|
521
|
+
r.description = "JSON response"
|
|
522
|
+
r.serialize UserSerializer
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Responders switch their serializer type depending on their resulting content type.
|
|
527
|
+
|
|
528
|
+
This is a responder that accepts HTML requests, and responds with JSON.
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
e.respond statuses: 200..299, accepts: :html, content_type: :json do |r|
|
|
532
|
+
# Using an inline JSON serializer this time
|
|
533
|
+
e.serialize do
|
|
534
|
+
attribute :name, String
|
|
535
|
+
attribute :age, Integer
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Responders can accept wildcard media types, and an endpoint can define multiple responders, from more to less specific.
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
e.respond 200, :json, UserSerializer
|
|
544
|
+
e.respond 200, 'text/*', UserTextSerializer
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Header schemas
|
|
548
|
+
|
|
549
|
+
`Endpoint#header_schema` is similar to `#query_schema` and `#payload_schema`, and it allows to define schemas to validate and/or coerce request headers.
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
api.get :list_users, '/users' do |e|
|
|
553
|
+
# Coerce some expected request headers
|
|
554
|
+
# This coerces the APIVersion header to a number
|
|
555
|
+
e.header_schema(
|
|
556
|
+
'APIVersion' => Steppe::Types::Lax::Numeric
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Downstream handlers will get a numeric header value
|
|
560
|
+
e.step do |conn|
|
|
561
|
+
Logger.info conn.request.env['APIVersion'] # a number
|
|
562
|
+
conn
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
These header schemas are inclusive: they don't remove other headers not included in the schemas.
|
|
568
|
+
|
|
569
|
+
They also generate OpenAPI docs.
|
|
570
|
+
|
|
571
|
+
<img width="850" height="595" alt="CleanShot 2025-10-11 at 23 59 05" src="https://github.com/user-attachments/assets/c25e65f7-8733-42d9-a1b6-b93d815e2981" />
|
|
572
|
+
|
|
573
|
+
#### Header schema order matters
|
|
574
|
+
|
|
575
|
+
Like most things in Steppe, query schemas are registered as steps in a pipeline, so the order of registration matters.
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
# No header schema coercion yet, the header is a string here.
|
|
579
|
+
e.step do |conn|
|
|
580
|
+
Logger.info conn.request.env['APIVersion'] # a STRING
|
|
581
|
+
conn
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Register the schema as a step in the endpoint's pipeline
|
|
585
|
+
e.header_schema(
|
|
586
|
+
'APIVersion' => Steppe::Types::Lax::Numeric
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# By the time this new step runs
|
|
590
|
+
# the header schema above has coerced the headers
|
|
591
|
+
e.step do |conn|
|
|
592
|
+
Logger.info conn.request.env['APIVersion'] # a NUMBER
|
|
593
|
+
conn
|
|
594
|
+
end
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
#### Multiple header schemas
|
|
598
|
+
|
|
599
|
+
Like with `#query_schema` and `#payload_schema`, `#header_schema` can be invoked multiple times, which will register individual validation steps, but it will also merge those schemas into the top-level `Endpoint#header_schema`, which goes into OpenAPI docs.
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
api.get :list_users, '/users' do |e|
|
|
603
|
+
e.header_schema('ApiVersion' => Steppe::Types::Lax::Numeric)
|
|
604
|
+
# some more steps
|
|
605
|
+
e.step SomeHandler
|
|
606
|
+
# add to endpoint's header schema
|
|
607
|
+
e.header_schema('HTTP_AUTHORIZATION' => JWTParser)
|
|
608
|
+
# more steps ...
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Endpoint's header_schema includes all fields
|
|
612
|
+
UserAPI[:list_users].header_schema
|
|
613
|
+
# is a
|
|
614
|
+
Steppe::Types::Hash[
|
|
615
|
+
'ApiVersion' => Steppe::Types::Lax::Numeric,
|
|
616
|
+
'HTTP_AUTHORISATION' => JWTParser
|
|
617
|
+
]
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
#### Header schema composition
|
|
621
|
+
|
|
622
|
+
Custom steps that define their own `#header_schema` will also have their schemas merged into the endpoint's `#header_schema`, and automatically documented in OpenAPI.
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
class ListUsersAction
|
|
626
|
+
HEADER_SCHEMA = Steppe::Types::Hash['ClientVersion' => String]
|
|
627
|
+
|
|
628
|
+
# responding to this method will cause
|
|
629
|
+
# Steppe to merge this schema into the endpoint's
|
|
630
|
+
def header_schema = HEADER_SCHEMA
|
|
631
|
+
|
|
632
|
+
# The Step interface to handle requests
|
|
633
|
+
def call(conn)
|
|
634
|
+
Logger.info conn.request.env['ClientVersion']
|
|
635
|
+
# do something
|
|
636
|
+
users = User.page(conn.params[:page])
|
|
637
|
+
conn.valid users
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Note that this also applies to Security Schemes above. For example, the built-in `Steppe::Auth::Bearer` scheme defines a header schema to declare the `Authorization` header.
|
|
643
|
+
|
|
644
|
+
### Security Schemes (authentication and authorization)
|
|
645
|
+
|
|
646
|
+
Steppe follows the same design as [OpenAPI security schemes](https://swagger.io/docs/specification/v3_0/authentication/).
|
|
647
|
+
|
|
648
|
+
A service defines one or more security schemes, which can then be opted-in either by individual endpoints, or for all endpoints at once.
|
|
649
|
+
|
|
650
|
+
Steppe provides two built-in schemes: **Bearer token** authentication (with scopes) and **Basic** HTTP authentication. More coming later.
|
|
651
|
+
|
|
652
|
+
```ruby
|
|
653
|
+
UsersAPI = Steppe::Service.new do |api|
|
|
654
|
+
api.title = 'Users API'
|
|
655
|
+
api.description = 'API for managing users'
|
|
656
|
+
api.server(
|
|
657
|
+
url: 'http://localhost:9292',
|
|
658
|
+
description: 'local server'
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# Bearer token authentication with scopes
|
|
662
|
+
api.bearer_auth(
|
|
663
|
+
'BearerToken',
|
|
664
|
+
store: {
|
|
665
|
+
'admintoken' => %w[users:read users:write],
|
|
666
|
+
'publictoken' => %w[users:read],
|
|
667
|
+
}
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Basic HTTP authentication (username/password)
|
|
671
|
+
api.basic_auth(
|
|
672
|
+
'BasicAuth',
|
|
673
|
+
store: {
|
|
674
|
+
'admin' => 'secret123',
|
|
675
|
+
'user' => 'password456'
|
|
676
|
+
}
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# Endpoint definitions here
|
|
680
|
+
api.get :list_users, '/users' do |e|
|
|
681
|
+
# etc
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
api.post :create_user, '/users' do |e|
|
|
685
|
+
# etc
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
#### 1.a Per-endpoint security
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
# Each endpoint can opt-in to using registered security schemes
|
|
694
|
+
api.get :list_users, '/users' do |e|
|
|
695
|
+
e.description = 'List users'
|
|
696
|
+
|
|
697
|
+
# Bearer auth with scopes
|
|
698
|
+
e.security 'BearerToken', ['users:read']
|
|
699
|
+
# etc
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
api.post :create_user, '/users' do |e|
|
|
703
|
+
e.description = 'Create user'
|
|
704
|
+
|
|
705
|
+
# Basic auth (no scopes)
|
|
706
|
+
e.security 'BasicAuth'
|
|
707
|
+
# etc
|
|
708
|
+
end
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
A request without the Authorization header responds with 401
|
|
712
|
+
|
|
713
|
+
```
|
|
714
|
+
curl -i http://localhost:9292/users
|
|
715
|
+
|
|
716
|
+
HTTP/1.1 401 Unauthorized
|
|
717
|
+
content-type: application/json
|
|
718
|
+
vary: Origin
|
|
719
|
+
content-length: 47
|
|
720
|
+
|
|
721
|
+
{"http":{"status":401},"params":{},"errors":{}}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
A request with the wrong access token responds with 403
|
|
725
|
+
|
|
726
|
+
```
|
|
727
|
+
curl -i -H "Authorization: Bearer nope" http://localhost:9292/users
|
|
728
|
+
|
|
729
|
+
HTTP/1.1 401 Unauthorized
|
|
730
|
+
content-type: application/json
|
|
731
|
+
vary: Origin
|
|
732
|
+
content-length: 47
|
|
733
|
+
|
|
734
|
+
{"http":{"status":401},"params":{},"errors":{}}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
A response with valid token succeeds
|
|
738
|
+
|
|
739
|
+
```
|
|
740
|
+
curl -i -H "Authorization: Bearer publictoken" http://localhost:9292/users
|
|
741
|
+
|
|
742
|
+
HTTP/1.1 200 OK
|
|
743
|
+
content-type: application/json
|
|
744
|
+
vary: Origin
|
|
745
|
+
content-length: 262
|
|
746
|
+
|
|
747
|
+
{"users":[{"id":1,"name":"Alice","age":30,"email":"alice@server.com","address":"123 Great St"},{"id":2,"name":"Bob","age":25,"email":"bob@server.com","address":"23 Long Ave."},{"id":3,"name":"Bill","age":20,"email":"bill@server.com","address":"Bill's Mansion"}]}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
#### 1.b. Service-level security
|
|
751
|
+
|
|
752
|
+
Using the `#security` method at the service level registers that scheme for all endpoints defined after that
|
|
753
|
+
|
|
754
|
+
```ruby
|
|
755
|
+
UsersAPI = Steppe::Service.new do |api|
|
|
756
|
+
# etc
|
|
757
|
+
# Define the security scheme
|
|
758
|
+
api.bearer_auth('BearerToken', ...)
|
|
759
|
+
|
|
760
|
+
# Now apply the scheme to all endpoints in this service, with the same scopes
|
|
761
|
+
api.security 'BearerToken', ['users:read']
|
|
762
|
+
|
|
763
|
+
# all endpoints here enforce a bearer token with scope 'users:read'
|
|
764
|
+
api.get :list_users, '/users'
|
|
765
|
+
api.post :create_user, '/users'
|
|
766
|
+
# etc
|
|
767
|
+
end
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Note that the order of the `security` invocation matters.
|
|
771
|
+
The following example defines an un-authenticated `:root` endpoint, and then protects all further endpoints with the 'BearerToken` scheme.
|
|
772
|
+
|
|
773
|
+
```ruby
|
|
774
|
+
api.get :root, '/' # <= public endpoint
|
|
775
|
+
|
|
776
|
+
api.security 'BearerToken', ['users:read'] # <= applies to all endpoints after this
|
|
777
|
+
|
|
778
|
+
api.get :list_users, '/users'
|
|
779
|
+
api.post :create_user, '/users'
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
#### Automatic OpenAPI docs
|
|
783
|
+
|
|
784
|
+
The OpenAPI endpoint mounted via `api.specs('/openapi.json')` will include these security schemas.
|
|
785
|
+
This is how that shows in the [SwaggerUI](https://swagger.io/tools/swagger-ui/) tool.
|
|
786
|
+
|
|
787
|
+
<img width="922" height="812" alt="CleanShot 2025-10-11 at 23 46 02" src="https://github.com/user-attachments/assets/3bdecb81-8248-4437-a78a-c80dd7d44ebd" />
|
|
788
|
+
|
|
789
|
+
#### Custom bearer token store or basic credential stores
|
|
790
|
+
|
|
791
|
+
See the comments and interfaces in `lib/steppe/auth/*` to learn how to provide custom credential stores to the built-in security schemes. For example to store and fetch credentials from a database or file.
|
|
792
|
+
|
|
793
|
+
As an example:
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
api.bearer_auth 'BearerToken', store: RedisTokenStore.new(REDIS)
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
You can also implement stores to fetch tokens from a database, or to decode JWT tokens with a secret, etc.
|
|
800
|
+
|
|
801
|
+
#### Custom security schemes
|
|
802
|
+
|
|
803
|
+
`Service#bearer_auth` and `#basic_auth` are shortcuts to register built-in security schemes. You can use `Service#security_scheme` to register custom implementations.
|
|
804
|
+
|
|
805
|
+
```ruby
|
|
806
|
+
api.security_scheme MyCustomAuthentication.new(name: 'BulletProof')
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
The custom security scheme is expected to implement the following interface:
|
|
810
|
+
|
|
811
|
+
```
|
|
812
|
+
#name() => String
|
|
813
|
+
#handle(Steppe::Result, endpoint_expected_scopes) => Steppe::Result
|
|
814
|
+
#to_openapi() => Hash
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
An example:
|
|
818
|
+
|
|
819
|
+
```ruby
|
|
820
|
+
class MyCustomAuthentication
|
|
821
|
+
HEADER_NAME = 'X-API-Key'
|
|
822
|
+
|
|
823
|
+
attr_reader :name
|
|
824
|
+
|
|
825
|
+
def initialize(name:)
|
|
826
|
+
@name = name
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# @param conn [Steppe::Result::Continue]
|
|
830
|
+
# @param endpoint_scopes [Array<String>] scopes expected by this endpoint (if any)
|
|
831
|
+
# @return [Steppe::Result::Continue, Steppe::Result::Halt]
|
|
832
|
+
def handle(conn, _endpoint_scopes)
|
|
833
|
+
api_token = conn.request.env[HEADER_NAME]
|
|
834
|
+
return conn.respond_with(401).halt if api_token.nil?
|
|
835
|
+
|
|
836
|
+
return conn.respond_with(403).halt if api_token != 'super-secure-token'
|
|
837
|
+
|
|
838
|
+
# all good, continue handling the request
|
|
839
|
+
conn
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# This data will be included in the OpenAPI specification
|
|
843
|
+
# for this security scheme
|
|
844
|
+
# @see https://swagger.io/docs/specification/v3_0/authentication/
|
|
845
|
+
# @return [Hash]
|
|
846
|
+
def to_openapi
|
|
847
|
+
{
|
|
848
|
+
'type' => 'apiKey',
|
|
849
|
+
'in' => 'header',
|
|
850
|
+
'name' => HEADER_NAME
|
|
851
|
+
}
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
Security schemes can optionally implement [#query_schema](#query-schemas), [#payload_schemas](#payload-schemas) and [#header_schema](#header-schemas), which will be merged onto the endpoint's equivalents, and automatically added to OpenAPI documentation.
|
|
857
|
+
|
|
858
|
+
## Installation
|
|
859
|
+
|
|
860
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
861
|
+
|
|
862
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
863
|
+
|
|
864
|
+
$ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
865
|
+
|
|
866
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
867
|
+
|
|
868
|
+
$ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
## Development
|
|
872
|
+
|
|
873
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
874
|
+
|
|
875
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
876
|
+
|
|
877
|
+
## Contributing
|
|
878
|
+
|
|
879
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/steppe.
|
|
880
|
+
|
|
881
|
+
## License
|
|
882
|
+
|
|
883
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|