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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'steppe/auth'
4
+
5
+ module Steppe
6
+ class Service
7
+ VERBS = %i[get post put patch delete].freeze
8
+
9
+ class Server < Types::Data
10
+ attribute :url, Types::Forms::URI::HTTP
11
+ attribute? :description, String
12
+
13
+ def node_name = :server
14
+ end
15
+
16
+ class Tag < Types::Data
17
+ attribute :name, String
18
+ attribute :description, Types::String.nullable
19
+ attribute :external_docs, Types::Forms::URI::HTTP.nullable
20
+ def node_name = :tag
21
+ end
22
+
23
+ attr_reader :node_name, :servers, :tags, :security_schemes, :registered_security_schemes
24
+ attr_accessor :title, :description, :version
25
+
26
+ def initialize(&)
27
+ @lookup = {}
28
+ @title = ''
29
+ @description = ''
30
+ @version = '0.0.1'
31
+ @node_name = :service
32
+ @servers = []
33
+ @tags = []
34
+ @security_schemes = {}
35
+ @registered_security_schemes = {}
36
+ yield self if block_given?
37
+ freeze
38
+ end
39
+
40
+ def [](name) = @lookup[name]
41
+ def endpoints = @lookup.values
42
+
43
+ def server(args = {})
44
+ @servers << Server.parse(args)
45
+ self
46
+ end
47
+
48
+ def tag(name, description: nil, external_docs: nil)
49
+ @tags << Tag.parse(name:, description:, external_docs:)
50
+ self
51
+ end
52
+
53
+ # Register a Bearer token authentication security scheme.
54
+ # This is a convenience method that creates a Bearer auth scheme and registers it.
55
+ #
56
+ # @see https://swagger.io/docs/specification/v3_0/authentication/
57
+ # @see Auth::Bearer
58
+ #
59
+ # @param name [String] The security scheme name (used to reference in endpoints)
60
+ # @param store [Hash, Auth::TokenStoreInterface] Token store mapping tokens to scopes.
61
+ # Can be a Hash (converted to HashTokenStore) or a custom store implementing the TokenStoreInterface.
62
+ # @param format [String] Bearer token format hint for documentation (e.g., 'JWT', 'opaque')
63
+ # @return [self] Returns self for method chaining
64
+ #
65
+ # @example Basic usage with hash store
66
+ # service.bearer_auth 'api_key', store: {
67
+ # 'token123' => ['read:users', 'write:users'],
68
+ # 'token456' => ['read:posts']
69
+ # }
70
+ #
71
+ # @example With JWT format hint
72
+ # service.bearer_auth 'jwt_auth', store: my_token_store, format: 'JWT'
73
+ def bearer_auth(name, store: {}, format: 'string')
74
+ security_scheme Auth::Bearer.new(name, store:, format:)
75
+ end
76
+
77
+ # Register a Basic HTTP authentication security scheme.
78
+ # This is a convenience method that creates a Basic auth scheme and registers it.
79
+ #
80
+ # @see https://swagger.io/docs/specification/v3_0/authentication/
81
+ # @see Auth::Basic
82
+ #
83
+ # @param name [String] The security scheme name (used to reference in endpoints)
84
+ # @param store [Hash, Auth::Basic::CredentialsStoreInterface] Credentials store mapping usernames to passwords.
85
+ # Can be a Hash (converted to SimpleUserPasswordStore) or a custom store implementing the CredentialsStoreInterface.
86
+ # @return [self]
87
+ #
88
+ # @example Basic usage with hash store
89
+ # service.basic_auth 'BasicAuth', store: {
90
+ # 'admin' => 'secret123',
91
+ # 'user' => 'password456'
92
+ # }
93
+ #
94
+ # @example With custom credentials store
95
+ # class DatabaseCredentialsStore
96
+ # def lookup(username)
97
+ # user = User.find_by(username: username)
98
+ # user&.password_digest
99
+ # end
100
+ # end
101
+ #
102
+ # service.basic_auth 'BasicAuth', store: DatabaseCredentialsStore.new
103
+ #
104
+ # @example Using in an endpoint
105
+ # service.basic_auth 'BasicAuth', store: { 'admin' => 'secret' }
106
+ # service.get :protected, '/protected' do |e|
107
+ # e.security 'BasicAuth'
108
+ # # ... endpoint definition
109
+ # end
110
+ def basic_auth(name, store: {})
111
+ security_scheme Auth::Basic.new(name, store:)
112
+ end
113
+
114
+ # Register a security scheme for use in endpoints.
115
+ # Security schemes define authentication methods that can be applied to endpoints.
116
+ #
117
+ # @see https://swagger.io/docs/specification/v3_0/authentication/
118
+ # @see Auth::SecuritySchemeInterface
119
+ #
120
+ # @param scheme [Auth::SecuritySchemeInterface] A security scheme object implementing the SecuritySchemeInterface
121
+ # @return [self] Returns self for method chaining
122
+ #
123
+ # @example Register a custom security scheme
124
+ # bearer = Steppe::Auth::Bearer.new('my_auth', store: token_store)
125
+ # service.security_scheme(bearer)
126
+ #
127
+ # @example Register and use in an endpoint
128
+ # service.bearer_auth 'api_key', store: { 'token123' => ['read:users'] }
129
+ # service.get :users, '/users' do |e|
130
+ # e.security 'api_key', ['read:users']
131
+ # # ... endpoint definition
132
+ # end
133
+ def security_scheme(scheme)
134
+ scheme => Auth::SecuritySchemeInterface
135
+ @security_schemes[scheme.name] = scheme
136
+ self
137
+ end
138
+
139
+ # Apply a security requirement globally to endpoints defined after this call.
140
+ # This registers a security scheme with required scopes at the service level,
141
+ # making it apply to all endpoints defined after this method is called.
142
+ #
143
+ # IMPORTANT: Order matters! This method only applies security to endpoints
144
+ # defined AFTER it is called, not to endpoints defined before.
145
+ #
146
+ # @see https://swagger.io/docs/specification/v3_0/authentication/
147
+ # @see Endpoint#security
148
+ #
149
+ # @param scheme_name [String] The name of a registered security scheme
150
+ # @param scopes [Array<String>] Required scopes for this security requirement
151
+ # @return [self] Returns self for method chaining
152
+ # @raise [KeyError] If the security scheme has not been registered
153
+ #
154
+ # @example Apply Bearer auth globally to all endpoints defined after
155
+ # service.bearer_auth 'api_key', store: {
156
+ # 'token123' => ['read:users', 'write:users']
157
+ # }
158
+ # service.security 'api_key', ['read:users']
159
+ # # All endpoints defined below will require this security
160
+ #
161
+ # @example Order matters - security only applies to endpoints after the call
162
+ # service.bearer_auth 'api_key', store: tokens
163
+ # service.get :public_endpoint, '/public' { } # No security required
164
+ # service.security 'api_key', ['read:users']
165
+ # service.get :protected_endpoint, '/protected' { } # Security required
166
+ #
167
+ # @example Multiple security schemes
168
+ # service.bearer_auth 'api_key', store: tokens
169
+ # service.bearer_auth 'admin_key', store: admin_tokens
170
+ # service.security 'api_key', ['read:users']
171
+ # service.security 'admin_key', ['admin']
172
+ def security(scheme_name, scopes = [])
173
+ scheme = security_schemes.fetch(scheme_name)
174
+ @registered_security_schemes[scheme_name] = scopes
175
+ self
176
+ end
177
+
178
+ # A custom serializer that generates the OpenAPI specification in JSON format.
179
+ class OpenAPISerializer
180
+ # @param service [Steppe::Service] The service instance to generate the OpenAPI spec from.
181
+ def initialize(service)
182
+ @service = service
183
+ end
184
+
185
+ # @param conn [Steppe::Result]
186
+ # @return [String] JSON data
187
+ def render(conn)
188
+ spec = Steppe::OpenAPIVisitor.from_request(@service, conn.request)
189
+ JSON.dump(spec)
190
+ end
191
+ end
192
+
193
+ # Generates an endpoint that serves the OpenAPI specification in JSON format.
194
+ # @param path [String] The path where the OpenAPI spec will be available (default: '/')
195
+ def specs(path = '/')
196
+ get :__open_api, path do |e|
197
+ e.no_spec!
198
+ e.json 200..299, OpenAPISerializer.new(self)
199
+ end
200
+ end
201
+
202
+ # Registers all defined endpoints with the given router.
203
+ # The router is expected to respond to HTTP verb methods (e.g., get, post).
204
+ # ie. router.get '/users/:id', to: rack_endpoint
205
+ # @example
206
+ # app = MyService.route_with(Hanami::Router.new)
207
+ # run app
208
+ #
209
+ # @example
210
+ # app = Hanami::Router.new do
211
+ # scope '/api' do
212
+ # MyService.route_with(self)
213
+ # end
214
+ # end
215
+ #
216
+ # @param router [Object] A router instance that responds to HTTP verb methods (e.g., get, post).
217
+ # @return [Object] The router with registered endpoints.
218
+ def route_with(router)
219
+ endpoints.each do |endpoint|
220
+ router.public_send(endpoint.verb, endpoint.path.to_s, to: endpoint.to_rack)
221
+ end
222
+ router
223
+ end
224
+
225
+ VERBS.each do |verb|
226
+ define_method(verb) do |name, path, &block|
227
+ @lookup[name] = Endpoint.new(self, name, verb, path:, &block)
228
+ self
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ class StatusMap
5
+ def initialize
6
+ @responders = []
7
+ @index = nil
8
+ end
9
+
10
+ def <<(responder)
11
+ @responders << responder
12
+ build_index
13
+ self
14
+ end
15
+
16
+ def each(&block)
17
+ return enum_for(:each) unless block
18
+
19
+ @responders.each(&block)
20
+ end
21
+
22
+ def find(status)
23
+ lo = 0
24
+ hi = @index.size - 1
25
+
26
+ while lo <= hi
27
+ mid = (lo + hi) / 2
28
+ start, finish, responder = @index[mid]
29
+
30
+ if status < start
31
+ hi = mid - 1
32
+ elsif status > finish
33
+ lo = mid + 1
34
+ else
35
+ return responder
36
+ end
37
+ end
38
+
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def build_index
45
+ # Collect all boundary points with priorities
46
+ points = []
47
+ @responders.each_with_index do |responder, index|
48
+ range = responder.statuses
49
+ # Add index as priority - earlier additions have higher priority
50
+ points << [range.begin, :start, index, responder]
51
+ points << [range.end + 1, :end, index, responder]
52
+ end
53
+ # Sort by position, then by type (:end before :start at same position),
54
+ # then by priority (lower index first)
55
+ points.sort_by! { |pos, type, priority, _| [pos, type == :start ? 1 : 0, priority] }
56
+
57
+ # Build non-overlapping segments
58
+ segments = []
59
+ active = {} # Map from responder to priority
60
+ prev_point = nil
61
+
62
+ points.each do |point, type, priority, responder|
63
+ # If we have active responders and moved to a new point, create segment
64
+ if !active.empty? && prev_point && prev_point < point
65
+ # Use the highest priority responder (min priority value = first added)
66
+ winner = active.min_by { |_, p| p }.first
67
+ segments << [prev_point, point - 1, winner]
68
+ end
69
+
70
+ if type == :start
71
+ active[responder] = priority
72
+ else
73
+ active.delete(responder)
74
+ end
75
+
76
+ prev_point = point
77
+ end
78
+
79
+ @index = segments
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ module Utils
5
+ def self.deep_symbolize_keys(hash)
6
+ hash.each.with_object({}) do |(k, v), h|
7
+ value = case v
8
+ when Hash
9
+ deep_symbolize_keys(v)
10
+ when Array
11
+ v.map { |e| e.is_a?(Hash) ? deep_symbolize_keys(e) : e }
12
+ else
13
+ v
14
+ end
15
+ h[k.to_sym] = value
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ VERSION = "0.1.0"
5
+ end
data/lib/steppe.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+ require 'tempfile'
5
+
6
+ require_relative 'steppe/version'
7
+ require_relative 'steppe/content_type'
8
+
9
+ module Steppe
10
+ class Error < StandardError; end
11
+
12
+ Plumb.policy :desc, helper: true do |type, description|
13
+ type.metadata(description:)
14
+ end
15
+
16
+ Plumb.policy :example, helper: true do |type, example|
17
+ type.metadata(example:)
18
+ end
19
+
20
+ module Types
21
+ include Plumb::Types
22
+
23
+ class UploadedFile < Data
24
+ attribute :filename, String
25
+ attribute :type, String
26
+ attribute :name, String
27
+ attribute :tempfile, ::Tempfile
28
+ attribute :head, String
29
+
30
+ def self.node_name = :uploaded_file
31
+ end
32
+ end
33
+
34
+ module ContentTypes
35
+ JSON = ContentType.parse('application/json')
36
+ TEXT = ContentType.parse('text/plain')
37
+ end
38
+ end
39
+
40
+ require_relative 'steppe/request'
41
+ require_relative 'steppe/responder'
42
+ require_relative 'steppe/service'
43
+ require_relative 'steppe/endpoint'
44
+ require_relative 'steppe/openapi_visitor'
data/sig/steppe.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Steppe
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: steppe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ismael Celis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mustermann
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: papercraft
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mustermann-contrib
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: plumb
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.0.15
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.0.15
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ description: Composable, self-documenting REST APIs in Ruby
83
+ email:
84
+ - ismaelct@gmail.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - ".rspec"
90
+ - ".ruby-version"
91
+ - CHANGELOG.md
92
+ - CLAUDE.md
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - docs/README.md
97
+ - docs/styles.css
98
+ - examples/hanami.ru
99
+ - examples/service.rb
100
+ - examples/sinatra.rb
101
+ - lib/docs_builder.rb
102
+ - lib/steppe.rb
103
+ - lib/steppe/auth.rb
104
+ - lib/steppe/auth/basic.rb
105
+ - lib/steppe/auth/bearer.rb
106
+ - lib/steppe/content_type.rb
107
+ - lib/steppe/endpoint.rb
108
+ - lib/steppe/openapi_visitor.rb
109
+ - lib/steppe/request.rb
110
+ - lib/steppe/responder.rb
111
+ - lib/steppe/responder_registry.rb
112
+ - lib/steppe/result.rb
113
+ - lib/steppe/serializer.rb
114
+ - lib/steppe/service.rb
115
+ - lib/steppe/status_map.rb
116
+ - lib/steppe/utils.rb
117
+ - lib/steppe/version.rb
118
+ - sig/steppe.rbs
119
+ homepage: https://www.github.com/ismasan/steppe
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ homepage_uri: https://www.github.com/ismasan/steppe
124
+ source_code_uri: https://www.github.com/ismasan/steppe
125
+ changelog_uri: https://www.github.com/ismasan/steppe
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 3.0.0
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.6.9
141
+ specification_version: 4
142
+ summary: Composable, self-documenting REST APIs in Ruby
143
+ test_files: []