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
|
@@ -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
|