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.
@@ -0,0 +1,742 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mustermann'
4
+ require 'steppe/responder'
5
+ require 'steppe/responder_registry'
6
+ require 'steppe/result'
7
+
8
+ module Steppe
9
+ # Endpoint represents a single API endpoint with request validation, processing, and response handling.
10
+ #
11
+ # Inherits from Plumb::Pipeline to provide composable request processing through steps.
12
+ # Each endpoint defines an HTTP verb, URL path pattern, input validation schemas, processing
13
+ # logic, and response serialization strategies.
14
+ #
15
+ # @example Basic endpoint definition
16
+ # endpoint = Endpoint.new(:users_list, :get, path: '/users') do |e|
17
+ # # Define query parameter validation
18
+ # e.query_schema(
19
+ # page: Types::Integer.default(1),
20
+ # per_page: Types::Integer.default(20)
21
+ # )
22
+ #
23
+ # # Add processing steps
24
+ # e.step do |result|
25
+ # users = User.limit(result.params[:per_page]).offset((result.params[:page] - 1) * result.params[:per_page])
26
+ # result.continue(data: users)
27
+ # end
28
+ #
29
+ # # Define response serialization
30
+ # e.respond 200, :json, UserListSerializer
31
+ # end
32
+ #
33
+ # @example Endpoint with payload validation
34
+ # endpoint = Endpoint.new(:create_user, :post, path: '/users') do |e|
35
+ # e.payload_schema(
36
+ # name: Types::String,
37
+ # email: Types::String.email
38
+ # )
39
+ #
40
+ # e.step do |result|
41
+ # user = User.create(result.params)
42
+ # result.respond_with(201).continue(data: user)
43
+ # end
44
+ #
45
+ # e.respond 201, :json, UserSerializer
46
+ # end
47
+ #
48
+ # @see Plumb::Pipeline
49
+ # @see Result
50
+ # @see Responder
51
+ # @see ResponderRegistry
52
+ class Endpoint < Plumb::Pipeline
53
+ # These types are used in the respond method pattern matching.
54
+ MatchContentType = Types::String[ContentType::MIME_TYPE] | Types::Symbol
55
+ MatchStatus = Types::Integer | Types::Any[Range]
56
+
57
+ # Fallback responder used when no matching responder is found for a status/content-type combination.
58
+ # Returns a JSON error message indicating the missing responder configuration.
59
+ FALLBACK_RESPONDER = Responder.new(statuses: (100..599), accepts: 'application/json') do |r|
60
+ r.serialize do
61
+ attribute :message, String
62
+ def message = "no responder registered for response status: #{result.response.status}"
63
+ end
64
+ end
65
+
66
+ # Default serializer used for successful and error responses when no custom serializer is provided.
67
+ # Returns the HTTP status, params, and validation errors.
68
+ class DefaultEntitySerializer < Steppe::Serializer
69
+ attribute :http, Steppe::Types::Hash[status: Types::Integer.example(200)]
70
+ attribute :params, Steppe::Types::Hash.example({'param' => 'value'}.freeze)
71
+ attribute :errors, Steppe::Types::Hash.example({'param' => 'is invalid'}.freeze)
72
+
73
+ def http = { status: result.response.status }
74
+ def params = result.params
75
+ def errors = result.errors
76
+ end
77
+
78
+ DefaultHTMLSerializer = -> (conn) {
79
+ html5 {
80
+ head {
81
+ title "Default #{conn.response.status}"
82
+ }
83
+ body {
84
+ h1 "Default view"
85
+ dl {
86
+ dt "Response status:"
87
+ dd conn.response.status.to_s
88
+ dt "Parameters:"
89
+ dd conn.params.inspect
90
+ dt "Errors:"
91
+ dd conn.errors.inspect
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ # Internal step that validates HTTP headers against a schema.
98
+ # Validates headers from the Rack env and merges validated values back into the env.
99
+ # Returns 422 Unprocessable Entity if validation fails.
100
+ #
101
+ # @note HTTP header names in Rack env use the format 'HTTP_*' (e.g., 'HTTP_AUTHORIZATION')
102
+ # @note Security schemes often use this to validate required headers (e.g., Authorization)
103
+ class HeaderValidator
104
+ attr_reader :header_schema
105
+
106
+ # @param header_schema [Hash, Plumb::Composable] Schema definition for HTTP headers
107
+ def initialize(header_schema)
108
+ @header_schema = header_schema.is_a?(Hash) ? Types::Hash[header_schema] : header_schema
109
+ end
110
+
111
+ # Validates headers from the request environment.
112
+ #
113
+ # @param conn [Result] The current result/connection object
114
+ # @return [Result] Updated result with validated env or error response
115
+ def call(conn)
116
+ result = header_schema.resolve(conn.request.env)
117
+ conn.request.env.merge!(result.value)
118
+ return conn.respond_with(422).invalid(errors: { headers: result.errors }) unless result.valid?
119
+
120
+ conn.valid
121
+ end
122
+ end
123
+
124
+ # Internal step that validates query parameters against a schema.
125
+ # Merges validated query params into the result params hash.
126
+ # Returns 422 Unprocessable Entity if validation fails.
127
+ class QueryValidator
128
+ attr_reader :query_schema
129
+
130
+ # @param query_schema [Hash, Plumb::Composable] Schema definition for query parameters
131
+ def initialize(query_schema)
132
+ @query_schema = query_schema.is_a?(Hash) ? Types::Hash[query_schema] : query_schema
133
+ end
134
+
135
+ # @param conn [Result] The current result/connection object
136
+ # @return [Result] Updated result with validated params or error response
137
+ def call(conn)
138
+ result = query_schema.resolve(conn.request.steppe_url_params)
139
+ conn = conn.copy(params: conn.params.merge(result.value))
140
+ return conn if result.valid?
141
+
142
+ conn.respond_with(422).invalid(errors: result.errors)
143
+ end
144
+ end
145
+
146
+ # Internal step that validates request payload against a schema for a specific content type.
147
+ # Only validates if the request content type matches.
148
+ # Or if the request is form or multipart, which Rack::Request parses by default.
149
+ # Merges validated payload into result params.
150
+ # Returns 422 Unprocessable Entity if validation fails.
151
+ class PayloadValidator
152
+ attr_reader :content_type, :payload_schema
153
+
154
+ # @param content_type [String] Content type to validate (e.g., 'application/json')
155
+ def initialize(content_type, payload_schema)
156
+ @content_type = content_type
157
+ @payload_schema = payload_schema.is_a?(Hash) ? Types::Hash[payload_schema] : payload_schema
158
+ end
159
+
160
+ # @param conn [Result] The current result/connection object
161
+ # @return [Result] Updated result with validated params or error response
162
+ def call(conn)
163
+ # If form or multipart, treat as form data
164
+ data = nil
165
+ if conn.request.form_data?
166
+ data = Utils.deep_symbolize_keys(conn.request.POST)
167
+ elsif content_type.media_type == conn.request.media_type
168
+ # request.body was already parsed by parser associated to this media type
169
+ data = conn.request.body
170
+ else
171
+ return conn
172
+ end
173
+
174
+ result = payload_schema.resolve(data)
175
+ conn = conn.copy(params: conn.params.merge(result.value))
176
+ return conn if result.valid?
177
+
178
+ conn.respond_with(422).invalid(errors: result.errors)
179
+ end
180
+ end
181
+
182
+ # Internal step that parses request body based on content type.
183
+ # Supports JSON and plain text parsing out of the box.
184
+ # Returns 400 Bad Request if parsing fails.
185
+ class BodyParser
186
+ MissingParserError = Class.new(ArgumentError)
187
+
188
+ include Plumb::Composable
189
+
190
+ # Registry of content type parsers
191
+ def self.parsers
192
+ @parsers ||= {}
193
+ end
194
+
195
+ # Default JSON parser - parses body and symbolizes keys
196
+ parsers[ContentTypes::JSON.media_type] = proc do |request|
197
+ ::JSON.parse(request.body.read, symbolize_names: true)
198
+ end
199
+
200
+ # Default text parser - reads body as string
201
+ parsers[ContentTypes::TEXT.media_type] = proc do |request|
202
+ request.body.read
203
+ end
204
+
205
+ # Builds a parser for the given content type
206
+ # @raise [MissingParserError] if no parser is registered for the content type
207
+ def self.build(content_type)
208
+ parser = parsers[content_type.media_type]
209
+ raise MissingParserError, "No parser for content type: #{content_type}" unless parser
210
+
211
+ new(content_type, parser)
212
+ end
213
+
214
+ def initialize(content_type, parser)
215
+ @content_type = content_type
216
+ @parser = parser
217
+ end
218
+
219
+ def call(conn)
220
+ return conn unless @content_type.media_type == conn.request.media_type
221
+
222
+ if conn.request.body
223
+ body = @parser.call(conn.request)
224
+ # Maybe here we can just mutate the request?
225
+ conn.request.env[::Rack::RACK_INPUT] = body
226
+ return conn
227
+ # request = Steppe::Request.new(conn.request.env.merge(::Rack::RACK_INPUT => body))
228
+ # return conn.copy(request:)
229
+ end
230
+ conn
231
+ rescue StandardError => e
232
+ conn.respond_with(400).invalid(errors: { body: e.message })
233
+ end
234
+ end
235
+
236
+ attr_reader :rel_name, :payload_schemas, :responders, :path, :registered_security_schemes
237
+ attr_accessor :description, :tags
238
+
239
+ # Creates a new endpoint instance.
240
+ #
241
+ # @param rel_name [Symbol] Relation name for this endpoint (e.g., :users_list, :create_user)
242
+ # @param verb [Symbol] HTTP verb (:get, :post, :put, :patch, :delete, etc.)
243
+ # @param path [String] URL path pattern (supports Mustermann syntax, e.g., '/users/:id')
244
+ # @yield [endpoint] Configuration block that receives the endpoint instance
245
+ #
246
+ # @example
247
+ # Endpoint.new(:users_show, :get, path: '/users/:id') do |e|
248
+ # e.step { |result| result.continue(data: User.find(result.params[:id])) }
249
+ # e.respond 200, :json, UserSerializer
250
+ # end
251
+ def initialize(service, rel_name, verb, path: '/', &)
252
+ # Do not setup with block yet
253
+ super(freeze_after: false, &nil)
254
+ @service = service
255
+ @rel_name = rel_name
256
+ @verb = verb
257
+ @responders = ResponderRegistry.new
258
+ @query_schema = Types::Hash
259
+ @header_schema = Types::Hash
260
+ @payload_schemas = {}
261
+ @body_parsers = {}
262
+ @registered_security_schemes = {}
263
+ @description = 'An endpoint'
264
+ @specced = true
265
+ @tags = []
266
+
267
+ # This registers security schemes declared in the service
268
+ # which may declare their own header, query or payload schemas
269
+ service.registered_security_schemes.each do |name, scopes|
270
+ security name, scopes
271
+ end
272
+
273
+ # This registers a query_schema
274
+ # and a QueryValidator step
275
+ self.path = path
276
+
277
+ configure(&) if block_given?
278
+
279
+ # Register default responders for common status codes
280
+ respond 204, :json
281
+ respond 304, :json
282
+ respond 200..299, :json, DefaultEntitySerializer
283
+ # TODO: match any content type
284
+ # respond 304, '*/*'
285
+ respond 401..422, :json, DefaultEntitySerializer
286
+ respond 401..422, :html, DefaultHTMLSerializer
287
+ freeze
288
+ end
289
+
290
+ def inspect
291
+ %(<#{self.class}##{object_id} [#{rel_name}] #{verb.to_s.upcase} #{path}>)
292
+ end
293
+
294
+ # @return [Proc] Rack-compatible application callable
295
+ def to_rack
296
+ proc { |env| run(Steppe::Request.new(env)).response }
297
+ end
298
+
299
+ def specced? = @specced
300
+ def no_spec! = @specced = false
301
+
302
+ # Node name for OpenAPI documentation
303
+ def node_name = :endpoint
304
+
305
+ class SecurityStep
306
+ attr_reader :header_schema, :query_schema
307
+
308
+ def initialize(scheme, scopes: [])
309
+ @scheme = scheme
310
+ @scopes = scopes
311
+ @header_schema = scheme.respond_to?(:header_schema) ? scheme.header_schema : Types::Hash
312
+ @query_schema = scheme.respond_to?(:query_schema) ? scheme.query_schema : Types::Hash
313
+ end
314
+
315
+ def call(conn)
316
+ @scheme.handle(conn, @scopes)
317
+ end
318
+ end
319
+
320
+ # Apply a security scheme to this endpoint with required scopes.
321
+ # The security scheme must be registered in the parent Service using #security_scheme, #bearer_auth, or #basic_auth.
322
+ # This adds a processing step that validates authentication/authorization before other endpoint logic runs.
323
+ #
324
+ # @param scheme_name [String] Name of the security scheme (must match a registered scheme)
325
+ # @param scopes [Array<String>] Required permission scopes for this endpoint (not used for Basic auth)
326
+ # @return [void]
327
+ #
328
+ # @raise [KeyError] If the security scheme is not registered in the parent service
329
+ #
330
+ # @example Basic usage with Bearer authentication
331
+ # service.bearer_auth 'api_key', store: {
332
+ # 'token123' => ['read:users', 'write:users']
333
+ # }
334
+ #
335
+ # service.get :users, '/users' do |e|
336
+ # e.security 'api_key', ['read:users'] # Only tokens with read:users scope can access
337
+ # e.step { |result| result.continue(data: User.all) }
338
+ # e.json 200, UserListSerializer
339
+ # end
340
+ #
341
+ # @example Basic HTTP authentication
342
+ # service.basic_auth 'BasicAuth', store: {
343
+ # 'admin' => 'secret123',
344
+ # 'user' => 'password456'
345
+ # }
346
+ #
347
+ # service.get :protected, '/protected' do |e|
348
+ # e.security 'BasicAuth' # Basic auth doesn't use scopes
349
+ # e.step { |result| result.continue(data: { message: 'Protected resource' }) }
350
+ # e.json 200
351
+ # end
352
+ #
353
+ # @example Multiple scopes required (Bearer only)
354
+ # service.get :admin_users, '/admin/users' do |e|
355
+ # e.security 'api_key', ['read:users', 'admin:access']
356
+ # # ... endpoint definition
357
+ # end
358
+ #
359
+ # @note If authentication fails, returns 401 Unauthorized
360
+ # @note If authorization fails (missing required scopes), returns 403 Forbidden
361
+ # @see Service#security_scheme
362
+ # @see Service#bearer_auth
363
+ # @see Service#basic_auth
364
+ # @see Auth::Bearer#handle
365
+ # @see Auth::Basic#handle
366
+ def security(scheme_name, scopes = [])
367
+ scheme = service.security_schemes.fetch(scheme_name)
368
+ scheme_step = SecurityStep.new(scheme, scopes:)
369
+ @registered_security_schemes[scheme.name] = scopes
370
+ step scheme_step
371
+ end
372
+
373
+ # Defines or returns the HTTP header validation schema.
374
+ #
375
+ # When called with a schema argument, registers a HeaderValidator step to validate
376
+ # HTTP headers. When called without arguments, returns the current header schema.
377
+ # Header schemas are automatically merged from security schemes and other composable steps.
378
+ #
379
+ # @overload header_schema(schema)
380
+ # Define header validation schema
381
+ # @param schema [Hash, Plumb::Composable] Schema definition for HTTP headers
382
+ # @return [void]
383
+ # @example Validate custom header
384
+ # header_schema(
385
+ # 'HTTP_X_API_VERSION' => Types::String.options(['v1', 'v2']),
386
+ # 'HTTP_X_REQUEST_ID?' => Types::String.present
387
+ # )
388
+ #
389
+ # @example Validate Authorization header manually
390
+ # header_schema(
391
+ # 'HTTP_AUTHORIZATION' => Types::String[/^Bearer .+/]
392
+ # )
393
+ #
394
+ # @overload header_schema
395
+ # Get current header schema
396
+ # @return [Plumb::Composable] Current header schema
397
+ #
398
+ # @note HTTP header names in Rack env use the format 'HTTP_*' (e.g., 'HTTP_AUTHORIZATION')
399
+ # @note Optional headers can be specified with a '?' suffix (e.g., 'HTTP_X_CUSTOM?')
400
+ # @note Security schemes automatically add their header requirements via SecurityStep
401
+ #
402
+ # @see HeaderValidator
403
+ # @see Auth::Bearer#header_schema
404
+ def header_schema(sc = nil)
405
+ if sc
406
+ step(HeaderValidator.new(sc))
407
+ else
408
+ @header_schema
409
+ end
410
+ end
411
+
412
+ # Defines or returns the query parameter validation schema.
413
+ #
414
+ # When called with a schema argument, registers a QueryValidator step to validate
415
+ # query parameters. When called without arguments, returns the current query schema.
416
+ #
417
+ # @overload query_schema(schema)
418
+ # @param schema [Hash, Plumb::Composable] Schema definition for query parameters
419
+ # @return [void]
420
+ # @example
421
+ # query_schema(
422
+ # page: Types::Integer.default(1),
423
+ # search: Types::String.optional
424
+ # )
425
+ #
426
+ # @overload query_schema
427
+ # @return [Plumb::Composable] Current query schema
428
+ def query_schema(sc = nil)
429
+ if sc
430
+ step(QueryValidator.new(sc))
431
+ else
432
+ @query_schema
433
+ end
434
+ end
435
+
436
+ # Defines request body validation schema for a specific content type.
437
+ #
438
+ # Automatically registers a BodyParser step for the content type if not already registered,
439
+ # then registers a PayloadValidator step to validate the parsed body.
440
+ #
441
+ # @overload payload_schema(schema)
442
+ # Define JSON payload schema (default content type)
443
+ # @param schema [Hash, Plumb::Composable] Schema definition
444
+ # @example
445
+ # payload_schema(
446
+ # name: Types::String,
447
+ # email: Types::String.email
448
+ # )
449
+ #
450
+ # @overload payload_schema(content_type, schema)
451
+ # Define payload schema for specific content type
452
+ # @param content_type [String] Content type (e.g., 'application/xml')
453
+ # @param schema [Hash, Plumb::Composable] Schema definition
454
+ # @example
455
+ # payload_schema('application/xml', XMLUserSchema)
456
+ #
457
+ # @raise [ArgumentError] if arguments don't match expected patterns
458
+ def payload_schema(*args)
459
+ ctype, stp = case args
460
+ in [Hash => sc]
461
+ [ContentTypes::JSON, sc]
462
+ in [Plumb::Composable => sc]
463
+ [ContentTypes::JSON, sc]
464
+ in [MatchContentType => content_type, Hash => sc]
465
+ [content_type, sc]
466
+ in [MatchContentType => content_type, Plumb::Composable => sc]
467
+ [content_type, sc]
468
+ else
469
+ raise ArgumentError, "Invalid arguments: #{args.inspect}. Expects [Hash] or [Plumb::Composable], and an optional content type."
470
+ end
471
+
472
+ content_type = ContentType.parse(ctype)
473
+ unless @body_parsers[content_type]
474
+ step BodyParser.build(content_type)
475
+ @body_parsers[ctype] = true
476
+ end
477
+ step PayloadValidator.new(content_type, stp)
478
+ end
479
+
480
+ # Gets or sets the HTTP verb for this endpoint.
481
+ #
482
+ # @overload verb(verb)
483
+ # Sets the HTTP verb
484
+ # @param verb [Symbol] HTTP verb (:get, :post, :put, :patch, :delete, etc.)
485
+ # @return [Symbol] The set verb
486
+ #
487
+ # @overload verb
488
+ # Gets the current HTTP verb
489
+ # @return [Symbol] Current verb
490
+ def verb(vrb = nil)
491
+ @verb = vrb if vrb
492
+ @verb
493
+ end
494
+
495
+ # Convenience method to define a JSON responder.
496
+ #
497
+ # @param statuses [Integer, Range] Status code(s) to respond to (default: 200-299)
498
+ # @param serializer [Class, Proc, nil] Optional serializer class or block
499
+ # @yield [serializer] Optional block defining serializer inline
500
+ # @return [self] Returns self for method chaining
501
+ #
502
+ # @example With serializer class
503
+ # json 200, UserSerializer
504
+ #
505
+ # @example With inline block
506
+ # json 200 do
507
+ # attribute :name, String
508
+ # def name = result.data[:name]
509
+ # end
510
+ def json(statuses = (200...300), serializer = nil, &block)
511
+ respond(statuses:, accepts: :json) do |r|
512
+ r.description = "Response for status #{statuses}"
513
+ r.serialize serializer || block
514
+ end
515
+
516
+ self
517
+ end
518
+
519
+ # Convenience method to define an HTML responder.
520
+ #
521
+ # @param statuses [Integer, Range] Status code(s) to respond to (default: 200-299)
522
+ # @param view [Class, Proc, nil] Optional view class or block
523
+ # @yield Optional block defining view inline
524
+ # @return [self] Returns self for method chaining
525
+ #
526
+ # @example
527
+ # html 200, UserShowView
528
+ def html(statuses = (200...300), view = nil, &block)
529
+ respond(statuses, :html, view || block)
530
+
531
+ self
532
+ end
533
+
534
+ # Define how the endpoint responds to specific HTTP status codes and content types.
535
+ #
536
+ # Responders are registered in order and when ranges overlap, the first registered
537
+ # responder wins. This allows you to define specific handlers first, then fallback
538
+ # handlers for broader ranges.
539
+ #
540
+ # @overload respond(status)
541
+ # Basic responder for a single status code
542
+ # @param status [Integer] HTTP status code
543
+ # @yield [responder] Optional configuration block
544
+ # @example
545
+ # respond 200 # Basic 200 response
546
+ # respond 404 do |r|
547
+ # r.serialize ErrorSerializer
548
+ # end
549
+ #
550
+ # @overload respond(status, accepts)
551
+ # Responder for specific status and content type
552
+ # @param status [Integer] HTTP status code
553
+ # @param accepts [String, Symbol] Content type (e.g., :json, 'application/json')
554
+ # @yield [responder] Optional configuration block
555
+ # @example
556
+ # respond 200, :json
557
+ # respond 404, 'text/html' do |r|
558
+ # r.serialize ErrorPageView
559
+ # end
560
+ #
561
+ # @overload respond(status, accepts, serializer)
562
+ # Responder with predefined serializer
563
+ # @param status [Integer] HTTP status code
564
+ # @param accepts [String, Symbol] Content type
565
+ # @param serializer [Class, Proc] Serializer class or block
566
+ # @yield [responder] Optional configuration block
567
+ # @example
568
+ # respond 200, :json, UserListSerializer
569
+ # respond 404, :json, ErrorSerializer
570
+ #
571
+ # @overload respond(status_range, accepts, serializer)
572
+ # Responder for a range of status codes
573
+ # @param status_range [Range] Range of HTTP status codes
574
+ # @param accepts [String, Symbol] Content type
575
+ # @param serializer [Class, Proc] Serializer class or block
576
+ # @yield [responder] Optional configuration block
577
+ # @example
578
+ # # First registered wins in overlaps
579
+ # respond 201, :json, CreatedSerializer # Specific handler for 201
580
+ # respond 200..299, :json, SuccessSerializer # Fallback for other 2xx
581
+ #
582
+ # @overload respond(responder)
583
+ # Add a pre-configured Responder instance
584
+ # @param responder [Responder] Pre-configured responder
585
+ # @example
586
+ # custom = Steppe::Responder.new(statuses: 200, accepts: :xml) do |r|
587
+ # r.serialize XMLUserSerializer
588
+ # end
589
+ # respond custom
590
+ #
591
+ # @overload respond(**options)
592
+ # Responder with keyword arguments
593
+ # @option statuses [Integer, Range] Status code(s)
594
+ # @option accepts [String, Symbol] Content type
595
+ # @option content_type [String, Symbol] specific Content-Type header to add to response
596
+ # @option serializer [Class, Proc] Optional serializer
597
+ # @yield [responder] Optional configuration block
598
+ # @example
599
+ # respond statuses: 200..299, accepts: :json do |r|
600
+ # r.serialize SuccessSerializer
601
+ # end
602
+ #
603
+ # @return [self] Returns self for method chaining
604
+ # @raise [ArgumentError] When invalid argument combinations are provided
605
+ #
606
+ # @note Responders are resolved by ResponderRegistry using status code and Accept header
607
+ # @note When ranges overlap, first registered responder wins
608
+ # @note Default accept type is :json (application/json) when not specified
609
+ #
610
+ # @see Responder
611
+ # @see ResponderRegistry
612
+ # @see Serializer
613
+ def respond(*args, &)
614
+ case args
615
+ in [Responder => responder]
616
+ @responders << responder
617
+
618
+ in [MatchStatus => statuses]
619
+ @responders << Responder.new(statuses:, &)
620
+
621
+ in [MatchStatus => statuses, MatchContentType => accepts]
622
+ @responders << Responder.new(statuses:, accepts:, &)
623
+
624
+ in [MatchStatus => statuses, MatchContentType => accepts, Object => serializer]
625
+ @responders << Responder.new(statuses:, accepts:, serializer:, &)
626
+
627
+ in [Hash => kargs]
628
+ @responders << Responder.new(**kargs, &)
629
+
630
+ else
631
+ raise ArgumentError, "Invalid arguments: #{args.inspect}"
632
+ end
633
+
634
+ self
635
+ end
636
+
637
+ # Adds a debugging breakpoint step to the endpoint pipeline.
638
+ # Useful for development and troubleshooting.
639
+ #
640
+ # @return [void]
641
+ def debug!
642
+ step do |conn|
643
+ debugger
644
+ conn
645
+ end
646
+ end
647
+
648
+ # Executes the endpoint pipeline for a given request.
649
+ #
650
+ # Creates an initial Continue result and runs it through the pipeline.
651
+ #
652
+ # @param request [Steppe::Request, Rack::Request] The request object
653
+ # @return [Result] Processing result (Continue or Halt)
654
+ def run(request)
655
+ result = Result::Continue.new(nil, request:)
656
+ call(result)
657
+ end
658
+
659
+ # Main processing method that runs the endpoint pipeline and handles response.
660
+ #
661
+ # Flow:
662
+ # 1. Runs all registered steps (query validation, payload validation, business logic)
663
+ # 2. Resolves appropriate responder based on status code and Accept header
664
+ # 3. Runs responder pipeline to serialize and format response
665
+ #
666
+ # @param conn [Result] Initial result/connection object
667
+ # @return [Result] Final result with serialized response
668
+ def call(conn)
669
+ known_query_names = query_schema._schema.keys.map(&:to_sym)
670
+ known_query = conn.request.steppe_url_params.slice(*known_query_names)
671
+ conn.request.set_url_params!(known_query)
672
+ conn = super(conn)
673
+ accepts = conn.request.get_header('HTTP_ACCEPT') || ContentTypes::JSON
674
+ responder = responders.resolve(conn.response.status, accepts) || FALLBACK_RESPONDER
675
+ # Conn might be a Halt now, because a step halted processing.
676
+ # We set it back to Continue so that the responder pipeline
677
+ # can process it through its steps.
678
+ responder.call(conn.valid)
679
+ end
680
+
681
+ # Sets the URL path pattern and extracts path parameters into the query schema.
682
+ # Path parameters are marked with metadata(in: :path) for OpenAPI documentation.
683
+ # @param pt [String] URL path with tokens
684
+ # @return [Mustermann]
685
+ def path=(pt)
686
+ @path = Mustermann.new(pt)
687
+ sc = @path.names.each_with_object({}) do |name, h|
688
+ name = name.to_sym
689
+ field = @query_schema.at_key(name) || Steppe::Types::String
690
+ # field = field.metadata(in: :path)
691
+ h[name] = field
692
+ end
693
+ # Setup a new query validator
694
+ # and merge into @query_schema
695
+ query_schema(sc)
696
+
697
+ @path
698
+ end
699
+
700
+ private
701
+
702
+ attr_reader :service
703
+
704
+ # Hook called when adding steps to the pipeline.
705
+ # Automatically merges query and payload schemas from composable steps.
706
+ def prepare_step(callable)
707
+ merge_header_schema(callable.header_schema) if callable.respond_to?(:header_schema)
708
+ merge_query_schema(callable.query_schema) if callable.respond_to?(:query_schema)
709
+ merge_payload_schema(callable) if callable.respond_to?(:payload_schema)
710
+ callable
711
+ end
712
+
713
+ def merge_header_schema(sc)
714
+ @header_schema += sc
715
+ end
716
+
717
+ # Merges a query schema from a step into the endpoint's query schema.
718
+ # Annotates each parameter with metadata indicating whether it's a path or query parameter.
719
+ def merge_query_schema(sc)
720
+ annotated_sc = sc._schema.each_with_object({}) do |(k, v), h|
721
+ pin = @path.names.include?(k.to_s) ? :path : :query
722
+ h[k] = Plumb::Composable.wrap(v).metadata(in: pin)
723
+ end
724
+ @query_schema += annotated_sc
725
+ end
726
+
727
+ # Merges a payload schema from a step into the endpoint's payload schemas.
728
+ # Handles multiple content types and merges schemas if they support the + operator.
729
+ def merge_payload_schema(callable)
730
+ content_type = callable.respond_to?(:content_type) ? callable.content_type : ContentTypes::JSON
731
+ media_type = content_type.media_type
732
+
733
+ existing = @payload_schemas[media_type]
734
+ if existing && existing.respond_to?(:+)
735
+ existing += callable.payload_schema
736
+ else
737
+ existing = callable.payload_schema
738
+ end
739
+ @payload_schemas[media_type] = existing
740
+ end
741
+ end
742
+ end