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