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,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Steppe
|
|
4
|
+
class OpenAPIVisitor < Plumb::JSONSchemaVisitor
|
|
5
|
+
ENVELOPE = {
|
|
6
|
+
'openapi' => '3.0.0'
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def self.call(node, root: true)
|
|
10
|
+
data = new.visit(node)
|
|
11
|
+
return data unless root
|
|
12
|
+
|
|
13
|
+
ENVELOPE.merge(data)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_request(service, request)
|
|
17
|
+
data = call(service)
|
|
18
|
+
url = request.base_url.to_s
|
|
19
|
+
return data if data['servers'].any? { |s| s['url'] == url }
|
|
20
|
+
|
|
21
|
+
data['servers'] << { 'url' => url, 'description' => 'Current server' }
|
|
22
|
+
data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
on(:service) do |node, props|
|
|
26
|
+
props.merge(
|
|
27
|
+
'info' => {
|
|
28
|
+
'title' => node.title,
|
|
29
|
+
'description' => node.description,
|
|
30
|
+
'version' => node.version
|
|
31
|
+
},
|
|
32
|
+
'servers' => node.servers.map { |s| visit(s) },
|
|
33
|
+
'tags' => node.tags.map { |s| visit(s) },
|
|
34
|
+
'paths' => node.endpoints.reduce({}) { |memo, e| visit(e, memo) },
|
|
35
|
+
'components' => { 'securitySchemes' => visit_security_schemes(node.security_schemes) }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
on(:server) do |node, _props|
|
|
40
|
+
{ 'url' => node.url.to_s, 'description' => node.description }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
on(:tag) do |node, _props|
|
|
44
|
+
prop = { 'name' => node.name, 'description' => node.description }
|
|
45
|
+
prop['externalDocs'] = { 'url' => node.external_docs.to_s } if node.external_docs
|
|
46
|
+
prop
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
on(:endpoint) do |node, paths|
|
|
50
|
+
return paths unless node.specced?
|
|
51
|
+
|
|
52
|
+
path_template = node.path.to_templates.first
|
|
53
|
+
path = paths[path_template] || {}
|
|
54
|
+
verb = path[node.verb.to_s] || {}
|
|
55
|
+
verb = verb.merge(
|
|
56
|
+
'summary' => node.rel_name.to_s,
|
|
57
|
+
# operationId can be used for links
|
|
58
|
+
# https://swagger.io/docs/specification/links/
|
|
59
|
+
'operationId' => node.rel_name.to_s,
|
|
60
|
+
'description' => node.description,
|
|
61
|
+
'tags' => node.tags,
|
|
62
|
+
'security' => visit_endpoint_security(node.registered_security_schemes),
|
|
63
|
+
'parameters' => visit_parameters(node.query_schema, node.header_schema),
|
|
64
|
+
'requestBody' => visit_request_body(node.payload_schemas),
|
|
65
|
+
'responses' => visit(node.responders)
|
|
66
|
+
)
|
|
67
|
+
path = path.merge(node.verb.to_s => verb)
|
|
68
|
+
paths.merge(path_template => path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
on(:responders) do |responders, _props|
|
|
72
|
+
responders.reduce({}) { |memo, r| visit(r, memo) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
on(:responder) do |responder, props|
|
|
76
|
+
# Naive implementation
|
|
77
|
+
# of OpenAPI responses status ranges
|
|
78
|
+
# https://swagger.io/docs/specification/describing-responses/
|
|
79
|
+
# TODO: OpenAPI only allows 1XX, 2XX, 3XX, 4XX, 5XX
|
|
80
|
+
status = responder.statuses.size == 1 ? responder.statuses.first.to_s : "#{responder.statuses.first.to_s[0]}XX"
|
|
81
|
+
status_prop = props[status]
|
|
82
|
+
return props if status_prop
|
|
83
|
+
return props unless responder.content_type.subtype == 'json'
|
|
84
|
+
|
|
85
|
+
status_prop = {}
|
|
86
|
+
content = status_prop['content'] || {}
|
|
87
|
+
content = content.merge(
|
|
88
|
+
responder.accepts.to_s => {
|
|
89
|
+
'schema' => visit(responder.serializer)
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
status_prop = status_prop.merge(
|
|
93
|
+
'description' => responder.description,
|
|
94
|
+
'content' => content
|
|
95
|
+
)
|
|
96
|
+
props.merge(status => status_prop)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
on(:uploaded_file) do |_node, props|
|
|
100
|
+
props.merge('type' => 'string', 'format' => 'byte')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
PARAMETERS_IN = %i[query path].freeze
|
|
104
|
+
|
|
105
|
+
def visit_endpoint_security(schemes)
|
|
106
|
+
schemes.map { |name, scopes| { name => scopes } }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def visit_parameters(query_schema, header_schema)
|
|
110
|
+
specs = query_schema._schema.each.with_object({}) do |(name, type), h|
|
|
111
|
+
h[name.to_s] = type if PARAMETERS_IN.include?(type.metadata[:in])
|
|
112
|
+
end
|
|
113
|
+
params = specs.map do |name, type|
|
|
114
|
+
spec = visit(type)
|
|
115
|
+
|
|
116
|
+
ins = spec.delete('in')&.to_s
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
'name' => name,
|
|
120
|
+
'in' => ins,
|
|
121
|
+
'description' => spec.delete('description'),
|
|
122
|
+
'example' => spec.delete('example'),
|
|
123
|
+
'required' => (ins == 'path'),
|
|
124
|
+
'schema' => spec.except('in', 'desc', 'options')
|
|
125
|
+
}.compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
header_schema._schema.each.with_object(params) do |(key, type), list|
|
|
129
|
+
spec = visit(type)
|
|
130
|
+
list << {
|
|
131
|
+
'name' => key.to_s,
|
|
132
|
+
'in' => 'header',
|
|
133
|
+
'description' => spec.delete('description'),
|
|
134
|
+
'example' => spec.delete('example'),
|
|
135
|
+
'required' => !key.optional?,
|
|
136
|
+
'schema' => spec.except('in', 'desc', 'options')
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def visit_request_body(schemas)
|
|
142
|
+
return {} if schemas.empty?
|
|
143
|
+
|
|
144
|
+
content = schemas.each.with_object({}) do |(content_type, schema), h|
|
|
145
|
+
h[content_type] = { 'schema' => visit(schema) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
{ 'required' => true, 'content' => content }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def visit_security_schemes(schemes)
|
|
152
|
+
schemes.transform_values(&:to_openapi)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack/request'
|
|
4
|
+
require 'steppe/utils'
|
|
5
|
+
|
|
6
|
+
module Steppe
|
|
7
|
+
class Request < Rack::Request
|
|
8
|
+
ROUTER_PARAMS = 'router.params'
|
|
9
|
+
BLANK_HASH = {}.freeze
|
|
10
|
+
|
|
11
|
+
def steppe_url_params
|
|
12
|
+
@steppe_url_params ||= begin
|
|
13
|
+
upstream_params = env[ROUTER_PARAMS] || BLANK_HASH
|
|
14
|
+
Utils.deep_symbolize_keys(params).merge(Utils.deep_symbolize_keys(upstream_params))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_url_params!(params)
|
|
19
|
+
@steppe_url_params = params
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'papercraft'
|
|
4
|
+
require 'steppe/content_type'
|
|
5
|
+
require 'steppe/serializer'
|
|
6
|
+
|
|
7
|
+
module Steppe
|
|
8
|
+
# Handles response formatting for specific HTTP status codes and content types.
|
|
9
|
+
#
|
|
10
|
+
# A Responder is a pipeline that processes a result and formats it into a Rack response.
|
|
11
|
+
# Each responder is registered for a specific range of status codes and content type,
|
|
12
|
+
# and includes a serializer to format the response body.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic JSON responder
|
|
15
|
+
# Responder.new(statuses: 200, accepts: :json) do |r|
|
|
16
|
+
# r.serialize do
|
|
17
|
+
# attribute :message, String
|
|
18
|
+
# def message = "Success"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example HTML responder with Papercraft
|
|
23
|
+
# Responder.new(statuses: 200, accepts: :html) do |r|
|
|
24
|
+
# r.serialize do |result|
|
|
25
|
+
# html5 do
|
|
26
|
+
# h1 result.params[:title]
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @example Responder for a range of status codes
|
|
32
|
+
# Responder.new(statuses: 200..299, accepts: :json) do |r|
|
|
33
|
+
# r.description = "Successful responses"
|
|
34
|
+
# r.serialize SuccessSerializer
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @see Serializer
|
|
38
|
+
# @see PapercraftSerializer
|
|
39
|
+
class Responder < Plumb::Pipeline
|
|
40
|
+
DEFAULT_STATUSES = (200..200).freeze
|
|
41
|
+
DEFAULT_SERIALIZER = Types::Static[{}.freeze].freeze
|
|
42
|
+
|
|
43
|
+
def self.inline_serializers
|
|
44
|
+
@inline_serializers ||= {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
inline_serializers[:json] = proc do |block|
|
|
48
|
+
block.is_a?(Proc) ? Class.new(Serializer, &block) : block
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
inline_serializers[:html] = proc do |block|
|
|
52
|
+
block
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Range] The range of HTTP status codes this responder handles
|
|
56
|
+
attr_reader :statuses
|
|
57
|
+
|
|
58
|
+
# @return [ContentType] The content type pattern this responder matches (from Accept header)
|
|
59
|
+
attr_reader :accepts
|
|
60
|
+
|
|
61
|
+
# @return [ContentType] The actual Content-Type header value set in the response
|
|
62
|
+
attr_reader :content_type
|
|
63
|
+
|
|
64
|
+
# @return [Serializer, Proc] The serializer used to format the response body
|
|
65
|
+
attr_reader :serializer
|
|
66
|
+
|
|
67
|
+
# @return [String, nil] Optional description of this responder (used in documentation)
|
|
68
|
+
attr_accessor :description
|
|
69
|
+
|
|
70
|
+
# Creates a new Responder instance.
|
|
71
|
+
#
|
|
72
|
+
# @param statuses [Integer, Range] HTTP status code(s) to handle (default: 200)
|
|
73
|
+
# @param accepts [String, Symbol] Content type to match from Accept header (default: :json)
|
|
74
|
+
# @param content_type [String, Symbol, nil] Specific Content-Type header for response (defaults to accepts value)
|
|
75
|
+
# @param serializer [Class, Proc, nil] Serializer to format response body
|
|
76
|
+
# @yield [responder] Optional configuration block
|
|
77
|
+
# @yieldparam responder [Responder] self for configuration
|
|
78
|
+
#
|
|
79
|
+
# @example Basic responder
|
|
80
|
+
# Responder.new(statuses: 200, accepts: :json)
|
|
81
|
+
#
|
|
82
|
+
# @example With custom Content-Type header
|
|
83
|
+
# Responder.new(statuses: 200, accepts: :json, content_type: 'application/vnd.api+json')
|
|
84
|
+
#
|
|
85
|
+
# @example With inline serializer
|
|
86
|
+
# Responder.new(statuses: 200, accepts: :json) do |r|
|
|
87
|
+
# r.serialize { attribute :data, Object }
|
|
88
|
+
# end
|
|
89
|
+
def initialize(statuses: DEFAULT_STATUSES, accepts: ContentTypes::JSON, content_type: nil, serializer: nil, &)
|
|
90
|
+
@statuses = statuses.is_a?(Range) ? statuses : (statuses..statuses)
|
|
91
|
+
@description = nil
|
|
92
|
+
@accepts = ContentType.parse(accepts)
|
|
93
|
+
@content_type = content_type ? ContentType.parse(content_type) : @accepts
|
|
94
|
+
@content_type_subtype = @content_type.subtype.to_sym
|
|
95
|
+
super(freeze_after: false, &)
|
|
96
|
+
serialize(serializer) if serializer
|
|
97
|
+
freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Registers a serializer for this responder.
|
|
101
|
+
#
|
|
102
|
+
# The serializer is selected based on the content type's subtype (:json or :html).
|
|
103
|
+
# For JSON responses, pass a Serializer class or a block that defines attributes.
|
|
104
|
+
# For HTML responses, pass a Papercraft template or block.
|
|
105
|
+
#
|
|
106
|
+
# @param serializer [Class, Proc, nil] Serializer class or template
|
|
107
|
+
# @yield Block to define inline serializer
|
|
108
|
+
#
|
|
109
|
+
# @raise [ArgumentError] If responder already has a serializer
|
|
110
|
+
#
|
|
111
|
+
# @example JSON serializer with block
|
|
112
|
+
# responder.serialize do
|
|
113
|
+
# attribute :users, [UserType]
|
|
114
|
+
# def users = result.params[:users]
|
|
115
|
+
# end
|
|
116
|
+
#
|
|
117
|
+
# @example JSON serializer with class
|
|
118
|
+
# responder.serialize(UserListSerializer)
|
|
119
|
+
#
|
|
120
|
+
# @example HTML serializer with Papercraft
|
|
121
|
+
# responder.serialize do |result|
|
|
122
|
+
# html5 { h1 result.params[:title] }
|
|
123
|
+
# end
|
|
124
|
+
#
|
|
125
|
+
# @return [void]
|
|
126
|
+
def serialize(serializer = nil, &block)
|
|
127
|
+
raise ArgumentError, "this responder already has a serializer" if @serializer
|
|
128
|
+
|
|
129
|
+
builder = self.class.inline_serializers.fetch(@content_type_subtype)
|
|
130
|
+
@serializer = builder.call(serializer || block)
|
|
131
|
+
step do |conn|
|
|
132
|
+
output = @serializer.render(conn)
|
|
133
|
+
conn.copy(value: output)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Compares two responders for equality.
|
|
138
|
+
#
|
|
139
|
+
# Two responders are equal if they handle the same status codes and content type.
|
|
140
|
+
#
|
|
141
|
+
# @param other [Object] Object to compare
|
|
142
|
+
# @return [Boolean] True if responders are equal
|
|
143
|
+
def ==(other)
|
|
144
|
+
other.is_a?(Responder) && other.statuses == statuses && other.content_type == content_type
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [String] Human-readable representation of the responder
|
|
148
|
+
def inspect = "<#{self.class}##{object_id} #{description} statuses:#{statuses} content_type:#{content_type}>"
|
|
149
|
+
|
|
150
|
+
# @return [Symbol] Node name for pipeline inspection
|
|
151
|
+
def node_name = :responder
|
|
152
|
+
|
|
153
|
+
# Processes the result through the serializer pipeline and creates a Rack response.
|
|
154
|
+
#
|
|
155
|
+
# @param conn [Result] The result object to process
|
|
156
|
+
# @return [Result::Halt] Result with Rack response
|
|
157
|
+
def call(conn)
|
|
158
|
+
conn = super(conn)
|
|
159
|
+
conn.respond_with(conn.response.status) do |response|
|
|
160
|
+
response[Rack::CONTENT_TYPE] = content_type.to_s
|
|
161
|
+
Rack::Response.new(conn.value, response.status, response.headers)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'steppe/status_map'
|
|
4
|
+
require 'steppe/content_type'
|
|
5
|
+
|
|
6
|
+
module Steppe
|
|
7
|
+
class ResponderRegistry
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
WILDCARD = '*'
|
|
11
|
+
|
|
12
|
+
attr_reader :node_name
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@map = {}
|
|
16
|
+
@node_name = :responders
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def freeze
|
|
20
|
+
@map.each_value(&:freeze)
|
|
21
|
+
@map.freeze
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def <<(responder)
|
|
26
|
+
accepts = responder.accepts
|
|
27
|
+
@map[accepts.type] ||= {}
|
|
28
|
+
@map[accepts.type][accepts.subtype] ||= StatusMap.new
|
|
29
|
+
@map[accepts.type][accepts.subtype] << responder
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each(&block)
|
|
34
|
+
return enum_for(:each) unless block_given?
|
|
35
|
+
|
|
36
|
+
@map.each_value do |subtype_map|
|
|
37
|
+
subtype_map.each_value do |status_map|
|
|
38
|
+
status_map.each(&block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve(response_status, accepted_content_types)
|
|
44
|
+
content_types = ContentType.parse_accept(accepted_content_types)
|
|
45
|
+
status_map = find_status_map(content_types)
|
|
46
|
+
status_map&.find(response_status.to_i)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def find_status_map(content_types)
|
|
52
|
+
content_types.each do |ct|
|
|
53
|
+
# For each content type, try to find the most specific match
|
|
54
|
+
# 1. application/json
|
|
55
|
+
# 2. application/* return first available subtype
|
|
56
|
+
# If no match, try the next content type in the list
|
|
57
|
+
# If no match, return try '*/*'
|
|
58
|
+
# If no match, return nil
|
|
59
|
+
#
|
|
60
|
+
# If accepts is '*/*', return the first available responder
|
|
61
|
+
return @map.values.first.values.first if ct.type == WILDCARD
|
|
62
|
+
|
|
63
|
+
type_level = @map[ct.type] # 'application'
|
|
64
|
+
next unless type_level
|
|
65
|
+
|
|
66
|
+
if ct.subtype == WILDCARD # find first available subtype. More specific ones should be first
|
|
67
|
+
return type_level.values.first
|
|
68
|
+
else
|
|
69
|
+
status_map = type_level[ct.subtype] # 'application/json'
|
|
70
|
+
status_map ||= type_level[WILDCARD] # 'application/*'
|
|
71
|
+
return status_map if status_map
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
wildcard_level = @map[WILDCARD]
|
|
76
|
+
wildcard_level ? wildcard_level[WILDCARD] : nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
|
|
5
|
+
module Steppe
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :value, :params, :errors, :request, :response
|
|
8
|
+
|
|
9
|
+
def initialize(value, params: {}, errors: {}, request:, response: nil)
|
|
10
|
+
@value = value
|
|
11
|
+
@params = params
|
|
12
|
+
@errors = errors
|
|
13
|
+
@request = request
|
|
14
|
+
@response = response || Rack::Response.new('', 200, {})
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def valid? = true
|
|
18
|
+
def invalid? = !valid?
|
|
19
|
+
# TODO: continue and valid are different things.
|
|
20
|
+
# continue = pipeline can proceed with next step
|
|
21
|
+
# valid = result has no errors.
|
|
22
|
+
def continue? = valid?
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
%(<#{self.class}##{object_id} [#{response.status}] value:#{value.inspect} errors:#{errors.inspect}>)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def copy(value: @value, params: @params, errors: @errors, request: @request, response: @response)
|
|
29
|
+
self.class.new(value, params:, errors:, request:, response:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def respond_with(status = nil, &)
|
|
33
|
+
response.status = status if status
|
|
34
|
+
@response = yield(response) if block_given?
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset(value)
|
|
39
|
+
@value = value
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def valid(val = value)
|
|
44
|
+
Continue.new(val, params:, errors:, request:, response:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def invalid(val = value, errors: {})
|
|
48
|
+
Halt.new(val, params:, errors:, request:, response:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def continue(...) = valid(...)
|
|
52
|
+
def halt(...) = invalid(...)
|
|
53
|
+
|
|
54
|
+
class Continue < self
|
|
55
|
+
def map(callable)
|
|
56
|
+
callable.call(self)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Halt < self
|
|
61
|
+
def valid? = false
|
|
62
|
+
|
|
63
|
+
def map(_)
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Steppe
|
|
6
|
+
# Base class for response serialization in Steppe endpoints.
|
|
7
|
+
#
|
|
8
|
+
# Serializers transform response data into structured output using Plumb types
|
|
9
|
+
# and attributes. They provide a declarative way to define the structure of
|
|
10
|
+
# response bodies with type validation and examples for OpenAPI documentation.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic serializer for a user resource
|
|
13
|
+
# class UserSerializer < Steppe::Serializer
|
|
14
|
+
# attribute :id, Types::Integer.example(1)
|
|
15
|
+
# attribute :name, Types::String.example('Alice')
|
|
16
|
+
# attribute :email, Types::Email.example('alice@example.com')
|
|
17
|
+
#
|
|
18
|
+
# # Optional: custom attribute methods
|
|
19
|
+
# def name
|
|
20
|
+
# object.full_name.titleize
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Nested serializers and arrays
|
|
25
|
+
# class UserListSerializer < Steppe::Serializer
|
|
26
|
+
# attribute :users, [UserSerializer]
|
|
27
|
+
# attribute :count, Types::Integer
|
|
28
|
+
# attribute :page, Types::Integer.default(1)
|
|
29
|
+
#
|
|
30
|
+
# def users
|
|
31
|
+
# object # Assuming object is an array of user objects
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# def count
|
|
35
|
+
# object.size
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# @example Error response serializer
|
|
40
|
+
# class ErrorSerializer < Steppe::Serializer
|
|
41
|
+
# attribute :errors, Types::Hash.example({ 'name' => 'is required' })
|
|
42
|
+
# attribute :message, Types::String.example('Validation failed')
|
|
43
|
+
#
|
|
44
|
+
# def errors
|
|
45
|
+
# result.errors
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# def message
|
|
49
|
+
# 'Request validation failed'
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# @example Using in endpoint responses
|
|
54
|
+
# api.get :users, '/users' do |e|
|
|
55
|
+
# e.step do |conn|
|
|
56
|
+
# users = User.all
|
|
57
|
+
# conn.valid(users)
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# # Using a predefined serializer class
|
|
61
|
+
# e.serialize 200, UserListSerializer
|
|
62
|
+
#
|
|
63
|
+
# # Using inline serializer definition
|
|
64
|
+
# e.serialize 404 do
|
|
65
|
+
# attribute :error, String
|
|
66
|
+
# def error = 'Users not found'
|
|
67
|
+
# end
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# @example Accessing result context
|
|
71
|
+
# class UserWithMetaSerializer < Steppe::Serializer
|
|
72
|
+
# attribute :user, UserSerializer
|
|
73
|
+
# attribute :request_id, String
|
|
74
|
+
#
|
|
75
|
+
# def user
|
|
76
|
+
# object
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# def request_id
|
|
80
|
+
# result.request.env['HTTP_X_REQUEST_ID'] || 'unknown'
|
|
81
|
+
# end
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# @note Serializers automatically generate attribute reader methods that delegate
|
|
85
|
+
# to the @object instance variable (the response data)
|
|
86
|
+
# @note The #result method provides access to the full Result context including
|
|
87
|
+
# request, params, errors, and response data
|
|
88
|
+
# @note Type definitions support .example() for OpenAPI documentation generation
|
|
89
|
+
#
|
|
90
|
+
# @see Endpoint#serialize
|
|
91
|
+
# @see Responder#serialize
|
|
92
|
+
# @see Result
|
|
93
|
+
class Serializer
|
|
94
|
+
extend Plumb::Composable
|
|
95
|
+
include Plumb::Attributes
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
# Serialize an object using this serializer class.
|
|
99
|
+
#
|
|
100
|
+
# @private
|
|
101
|
+
# Internal method that defines attribute reader methods.
|
|
102
|
+
# Automatically creates methods that delegate to @object.attribute_name
|
|
103
|
+
def __plumb_define_attribute_reader_method__(name)
|
|
104
|
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
105
|
+
def #{name} = @object.#{name}
|
|
106
|
+
RUBY
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
RenderError = Class.new(StandardError)
|
|
110
|
+
|
|
111
|
+
# @param conn [Result] The result object containing the value to serialize
|
|
112
|
+
# @return [String, nil] JSON string of serialized value or nil if no value
|
|
113
|
+
def render(conn)
|
|
114
|
+
result = call(conn)
|
|
115
|
+
raise RenderError, result.errors if result.invalid?
|
|
116
|
+
|
|
117
|
+
result.value ? JSON.dump(result.value) : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @private
|
|
121
|
+
# Internal method called during endpoint processing to serialize results.
|
|
122
|
+
#
|
|
123
|
+
# @param conn [Result] The result object containing the value to serialize
|
|
124
|
+
# @return [Result] New result with serialized value
|
|
125
|
+
def call(conn)
|
|
126
|
+
hash = new(conn).serialize
|
|
127
|
+
conn.copy(value: hash)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @!attribute [r] object
|
|
132
|
+
# @return [Object] The object being serialized (same as result.value)
|
|
133
|
+
|
|
134
|
+
# @!attribute [r] result
|
|
135
|
+
# @return [Result] The full result context with request, params, errors, etc.
|
|
136
|
+
attr_reader :object, :result
|
|
137
|
+
|
|
138
|
+
# Initialize a new serializer instance.
|
|
139
|
+
#
|
|
140
|
+
# @param result [Result] The result object containing the value to serialize
|
|
141
|
+
# and full request context
|
|
142
|
+
def initialize(result)
|
|
143
|
+
@result = result
|
|
144
|
+
@object = result.value
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def conn = result
|
|
148
|
+
|
|
149
|
+
# Serialize the object to a hash using defined attributes.
|
|
150
|
+
#
|
|
151
|
+
# Iterates through all defined attributes, calls the corresponding method
|
|
152
|
+
# (either auto-generated or custom), and applies the attribute's type
|
|
153
|
+
# transformation and validation.
|
|
154
|
+
#
|
|
155
|
+
# @return [Hash] The serialized hash with symbol keys
|
|
156
|
+
# @example
|
|
157
|
+
# serializer = UserSerializer.new(result)
|
|
158
|
+
# serializer.serialize
|
|
159
|
+
# # => { id: 1, name: "Alice", email: "alice@example.com" }
|
|
160
|
+
def serialize
|
|
161
|
+
self.class._schema._schema.each.with_object({}) do |(key, type), ret|
|
|
162
|
+
ret[key.to_sym] = serialize_attribute(key.to_sym, type)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Serialize a single attribute using its defined type.
|
|
167
|
+
#
|
|
168
|
+
# @param key [Symbol] The attribute name
|
|
169
|
+
# @param type [Plumb::Type] The Plumb type definition for the attribute
|
|
170
|
+
# @return [Object] The serialized attribute value
|
|
171
|
+
# @example
|
|
172
|
+
# serialize_attribute(:name, Types::String)
|
|
173
|
+
# # => "Alice"
|
|
174
|
+
def serialize_attribute(key, type)
|
|
175
|
+
# Ex. value = self.name
|
|
176
|
+
value = send(key)
|
|
177
|
+
type.call(result.copy(value:)).value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|