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