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,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'steppe'
4
+
5
+ class User
6
+ Record = Data.define(:id, :name, :age, :email, :address)
7
+
8
+ class << self
9
+ def data
10
+ @data ||= [
11
+ Record.new(1, 'Alice', 30, 'alice@server.com', '123 Great St'),
12
+ Record.new(2, 'Bob', 25, 'bob@server.com', '23 Long Ave.'),
13
+ Record.new(3, 'Bill', 20, 'bill@server.com', "Bill's Mansion")
14
+ ]
15
+ end
16
+
17
+ def filter_by_name(name)
18
+ return data unless name
19
+
20
+ data.select { |u| u.name.downcase.start_with?(name.downcase) }
21
+ end
22
+
23
+ def find(id)
24
+ data.find { |u| u.id == id }
25
+ end
26
+
27
+ def update(id, attrs)
28
+ attrs.delete(:id)
29
+ user = find(id)
30
+ return unless user
31
+
32
+ idx = data.index(user)
33
+ user = user.with(**attrs)
34
+ data[idx] = user
35
+ user
36
+ end
37
+
38
+ def create(attrs)
39
+ rec = Record.new(
40
+ id: data.size + 1,
41
+ name: attrs[:name],
42
+ age: attrs[:age],
43
+ email: attrs[:email],
44
+ address: attrs[:address]
45
+ )
46
+ data << rec
47
+ rec
48
+ end
49
+ end
50
+ end
51
+
52
+ module Types
53
+ include Plumb::Types
54
+
55
+ UserCategory = String
56
+ .options(%w[any admin customer guest])
57
+ .default('any')
58
+ .desc('search by category')
59
+
60
+ DowncaseString = String.invoke(:downcase)
61
+ end
62
+
63
+ class UserSerializer < Steppe::Serializer
64
+ attribute :id, Types::Integer.example(1)
65
+ attribute :name, Types::String.example('Alice')
66
+ attribute :age, Types::Integer.example('34')
67
+ attribute :email, Types::String.example('alice@server.com')
68
+ attribute? :address, String
69
+ end
70
+
71
+ class HashTokenStore
72
+ def initialize(hash)
73
+ @hash = hash
74
+ end
75
+
76
+ def set(claims)
77
+ key = SecureRandom.hex
78
+ @hash[key] = claims.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
79
+ key
80
+ end
81
+
82
+ def get(key)
83
+ @hash[key]
84
+ end
85
+ end
86
+
87
+ class BooticAuth
88
+ attr_reader :scopes, :type, :description, :store
89
+
90
+ def initialize(authorization_url:, scopes: [], store: {})
91
+ @scopes = scopes
92
+ @type = :oauth2
93
+ @description = 'Bootic Auth'
94
+ @store = store.is_a?(Hash) ? HashTokenStore.new(store) : store
95
+ end
96
+ end
97
+
98
+ # bootic = BooticAuth.new(scopes: %w[admin god])
99
+
100
+ Service = Steppe::Service.new do |api|
101
+ api.title = 'Users API'
102
+ api.description = 'API for managing users'
103
+ api.server(
104
+ url: 'http://localhost:9292',
105
+ description: 'prod server'
106
+ )
107
+ api.tag(
108
+ 'users',
109
+ description: 'Users operations',
110
+ external_docs: 'https://example.com/docs/users'
111
+ )
112
+
113
+ api.specs('/')
114
+
115
+ # An endpoint to list users
116
+ api.get :users, '/users' do |e|
117
+ e.description = 'List users'
118
+ e.tags = %w[users]
119
+ # Custom steps, authentication, etc.
120
+ e.step do |conn|
121
+ puts conn.request.env['HTTP_AUTHORIZATION'].inspect
122
+ conn
123
+ end
124
+
125
+ # Validate and coerce URL parameters
126
+ e.query_schema(
127
+ q?: Types::DowncaseString.desc('search by name, supports partial matches').example('Bil, Jo'),
128
+ cat?: Types::UserCategory
129
+ )
130
+
131
+ # A step with your business logic
132
+ # In this case filtering users by name
133
+ # This step will only run if the params are valid
134
+ e.step do |conn|
135
+ users = User.filter_by_name(conn.params[:q])
136
+ conn.valid users
137
+ end
138
+
139
+ e.json do
140
+ attribute :users, [UserSerializer]
141
+
142
+ def users
143
+ object
144
+ end
145
+ end
146
+
147
+ # Or, use a named serializer class
148
+ # e.json 200, UserListSerializer
149
+ #
150
+ # Or, use #respond for more detailed control
151
+ # e.respond 200...300, :json, UserListSerializer
152
+ #
153
+ # Or, expand into a responder block
154
+ # e.respond 200...300, :json do |r|
155
+ # r.description = "A list of users"
156
+ # r.step CustomStep1
157
+ # r.serializer UserListSerializer
158
+ # r.step CustomStep1
159
+ # end
160
+ #
161
+ # Or, register named responder
162
+ # e.respond UserListResponder
163
+
164
+ # Respond with HTML
165
+ e.html do |conn|
166
+ html5 {
167
+ body {
168
+ h1 'Users'
169
+ ul {
170
+ conn.value.each do |user|
171
+ li {
172
+ text "#{user.id}:"
173
+ a(user.name, href: "/users/#{user.id}")
174
+ text " (#{user.age})"
175
+ }
176
+ end
177
+ }
178
+ }
179
+ }
180
+ end
181
+ end
182
+
183
+ UserName = Plumb::Types::String.desc('User name').example('Alice').present
184
+ UserAge = Steppe::Types::Lax::Integer[18..]
185
+ UserEmail = Steppe::Types::Email.desc('User email').example('alice@email.com')
186
+ UserAddress = Steppe::Types::String.desc('User address').example('123 Great St')
187
+
188
+ # A Standalone action class
189
+ # with its own schema
190
+ # FIXME: classes with their own payload_schema
191
+ # don't automatically register body parses, nor actually validate the schema
192
+ # because it's up to them to validate the params
193
+ class UpdateUser
194
+ QUERY_SCHEMA = Plumb::Types::Hash[id: Steppe::Types::Lax::Integer]
195
+ SCHEMA = Plumb::Types::Hash[
196
+ name?: UserName,
197
+ age?: UserAge,
198
+ email?: UserEmail,
199
+ address?: UserAddress
200
+ ]
201
+
202
+ def self.query_schema = QUERY_SCHEMA
203
+ def self.payload_schema = SCHEMA
204
+
205
+ def self.call(conn)
206
+ p conn.params.inspect
207
+ return conn
208
+ user = User.update(conn.params[:id], conn.params)
209
+ return conn.invalid(errors: { id: 'User not found' }) unless user
210
+
211
+ conn.valid user
212
+ end
213
+ end
214
+
215
+ # Another standalone action
216
+ # with its own schema
217
+ class ProcessFile
218
+ def self.payload_schema = Plumb::Types::Hash[
219
+ file: Steppe::Types::UploadedFile.with(type: 'text/plain')
220
+ ]
221
+
222
+ def self.call(conn)
223
+ # process the uploaded file
224
+ conn
225
+ end
226
+ end
227
+
228
+ api.put :update_user, '/users/:id' do |e|
229
+ e.description = 'Update a user'
230
+ e.tags = %w[users]
231
+
232
+ e.query_schema(
233
+ id: Steppe::Types::Lax::Integer.desc('User ID')
234
+ )
235
+
236
+ # Endpoint will consolidate schemas from steps
237
+ # that respond to .payload_schema
238
+ # e.step UpdateUser
239
+ e.payload_schema(
240
+ name?: UserName,
241
+ age?: UserAge,
242
+ email?: UserEmail,
243
+ address?: UserAddress
244
+ )
245
+ e.step do |conn|
246
+ user = User.update(conn.params[:id], conn.params)
247
+ user ? conn.valid(user) : conn.invalid(errors: { id: 'User not found' })
248
+ end
249
+
250
+ e.json 200, UserSerializer
251
+
252
+ # e.payload_schema(
253
+ # name: Steppe::Types::String.present,
254
+ # age: Steppe::Types::Lax::Integer[18..],
255
+ # file?: Steppe::Types::UploadedFile.with(type: 'text/plain')
256
+ # )
257
+ end
258
+
259
+ api.get :user, '/users/:id' do |e|
260
+ e.description = 'Fetch information for a user, by ID'
261
+ e.tags = %w[users]
262
+ e.query_schema(
263
+ id: Types::Lax::Integer.desc('User ID')
264
+ )
265
+ e.step do |conn|
266
+ user = User.find(conn.params[:id])
267
+ if user
268
+ conn.valid user
269
+ else
270
+ conn.response.status = 404
271
+ conn.invalid(errors: { id: 'User not found' })
272
+ end
273
+ end
274
+
275
+ e.json 200...300, UserSerializer
276
+
277
+ e.html do |conn|
278
+ html5 {
279
+ body {
280
+ a('users', href: '/users')
281
+ h1 'User'
282
+ dl {
283
+ dt 'ID'
284
+ dd conn.value.id
285
+ dt 'name'
286
+ dd conn.value.name
287
+ }
288
+ }
289
+ }
290
+ end
291
+ end
292
+
293
+ api.post :create_user, '/users' do |e|
294
+ e.tags = %w[users]
295
+ e.description = 'Create a user'
296
+
297
+ # Validate request BODY payload
298
+ # request body is parsed at this point in the pipeline
299
+ e.payload_schema(
300
+ name: UserName,
301
+ age: UserAge,
302
+ email: UserEmail,
303
+ address?: UserAddress
304
+ )
305
+
306
+ # Create a user, only if params above are valid
307
+ e.step do |conn|
308
+ user = User.create(conn.params)
309
+ conn.respond_with(201).valid user
310
+ end
311
+
312
+ # Serialize the user (valid case)
313
+ # status 422 (invalid) will be handled by default responder
314
+ e.json 201, UserSerializer
315
+
316
+ # Or, register a custom responder for 422
317
+ # e.serialize 422 do
318
+ # attribute :errors, Types::Hash
319
+ # private def errors = conn.errors
320
+ # end
321
+ end
322
+ end
323
+
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # From root, run with:
4
+ # bundle exec ruby examples/sinatra.rb -p 4567
5
+ #
6
+ require 'bundler'
7
+ Bundler.setup(:examples, :sinatra)
8
+
9
+ require 'sinatra/base'
10
+ require 'rack/cors'
11
+ require_relative './service'
12
+
13
+ class SinatraRequestWrapper < SimpleDelegator
14
+ def initialize(request, params)
15
+ super(request)
16
+ @steppe_url_params = params
17
+ end
18
+
19
+ attr_reader :steppe_url_params
20
+ end
21
+
22
+ class App < Sinatra::Base
23
+ use Rack::Cors do
24
+ allow do
25
+ origins '*'
26
+ resource '*', headers: :any, methods: :any
27
+ end
28
+ end
29
+
30
+ Service.endpoints.each do |endpoint|
31
+ public_send(endpoint.verb, endpoint.path.to_templates.first) do
32
+ resp = endpoint.run(SinatraRequestWrapper.new(request, params)).response
33
+ resp.finish
34
+ end
35
+ end
36
+
37
+ run! if 'examples/sinatra.rb' == $0
38
+ end
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'kramdown'
5
+ require 'fileutils'
6
+
7
+ class DocsBuilder
8
+ GITHUB_URL = 'https://github.com/ismasan/steppe'
9
+
10
+ def initialize(readme_path:, output_dir:)
11
+ @readme_path = readme_path
12
+ @output_dir = output_dir
13
+ @sections = []
14
+ end
15
+
16
+ def build
17
+ puts "Reading #{@readme_path}..."
18
+ markdown = File.read(@readme_path)
19
+
20
+ puts "Parsing markdown..."
21
+ doc = Kramdown::Document.new(markdown, input: 'GFM', auto_ids: true)
22
+
23
+ puts "Extracting structure..."
24
+ extract_structure(doc.root)
25
+
26
+ puts "Generating HTML..."
27
+ html = generate_html(doc)
28
+
29
+ puts "Writing to #{@output_dir}/index.html..."
30
+ FileUtils.mkdir_p(@output_dir)
31
+ File.write(File.join(@output_dir, 'index.html'), html)
32
+
33
+ puts "Done! Documentation built successfully."
34
+ end
35
+
36
+ private
37
+
38
+ def extract_structure(element, level = 0)
39
+ element.children.each do |child|
40
+ if child.type == :header
41
+ # Use Kramdown's auto-generated ID
42
+ id = child.attr['id']
43
+ title = extract_text(child)
44
+
45
+ @sections << {
46
+ level: child.options[:level],
47
+ id: id,
48
+ title: title,
49
+ element: child
50
+ }
51
+ end
52
+
53
+ extract_structure(child, level + 1) if child.children
54
+ end
55
+ end
56
+
57
+ def extract_text(element)
58
+ case element.type
59
+ when :text
60
+ element.value
61
+ when :codespan
62
+ element.value
63
+ when :header, :p, :strong, :em
64
+ element.children.map { |c| extract_text(c) }.join if element.children
65
+ else
66
+ if element.children && !element.children.empty?
67
+ element.children.map { |c| extract_text(c) }.join
68
+ else
69
+ ''
70
+ end
71
+ end
72
+ end
73
+
74
+ def generate_navigation
75
+ nav_items = []
76
+ current_section = nil
77
+
78
+ @sections.each do |section|
79
+ if section[:level] == 2
80
+ current_section = section
81
+ nav_items << %(<li><a href="##{section[:id]}">#{section[:title]}</a></li>)
82
+ elsif section[:level] == 3 && current_section
83
+ nav_items << %(<li class="nav-submenu"><a href="##{section[:id]}">#{section[:title]}</a></li>)
84
+ end
85
+ end
86
+
87
+ nav_items.join("\n ")
88
+ end
89
+
90
+ def generate_html(doc)
91
+ # Convert markdown to HTML
92
+ content_html = doc.to_html
93
+
94
+ # Process the HTML to add proper structure
95
+ content_html = wrap_sections(content_html)
96
+
97
+ <<~HTML
98
+ <!DOCTYPE html>
99
+ <html lang="en">
100
+ <head>
101
+ <meta charset="UTF-8">
102
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
103
+ <title>Steppe - Composable, self-documenting REST APIs for Ruby</title>
104
+ <link rel="stylesheet" href="styles.css">
105
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
106
+ </head>
107
+ <body>
108
+ <nav class="top-menu">
109
+ <div class="top-menu-content">
110
+ <div class="top-menu-brand">
111
+ <span class="brand-name">Steppe</span>
112
+ <span class="brand-tagline">Composable REST APIs for Ruby</span>
113
+ </div>
114
+ <a href="#{GITHUB_URL}" target="_blank" class="github-link" aria-label="View on GitHub">
115
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
116
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
117
+ </svg>
118
+ <span>GitHub</span>
119
+ </a>
120
+ </div>
121
+ </nav>
122
+ <div class="container">
123
+ <nav class="sidebar">
124
+ <div class="logo">
125
+ <h2>Steppe</h2>
126
+ <p class="tagline">REST APIs for Ruby</p>
127
+ </div>
128
+ <ul class="nav-menu">
129
+ #{generate_navigation}
130
+ </ul>
131
+ </nav>
132
+
133
+ <main class="content">
134
+ <header class="page-header">
135
+ <h1>Steppe</h1>
136
+ <p class="subtitle">Composable, self-documenting REST APIs for Ruby</p>
137
+ </header>
138
+
139
+ #{content_html}
140
+ </main>
141
+ </div>
142
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
143
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/ruby.min.js"></script>
144
+ <script>hljs.highlightAll();</script>
145
+ <script>
146
+ // Active section highlighting
147
+ const observerOptions = {
148
+ root: null,
149
+ rootMargin: '-20% 0px -60% 0px',
150
+ threshold: 0
151
+ };
152
+
153
+ const sections = document.querySelectorAll('section, article[id]');
154
+ const navLinks = document.querySelectorAll('.nav-menu a');
155
+
156
+ // Create a map of href to link elements
157
+ const linkMap = new Map();
158
+ navLinks.forEach(link => {
159
+ const href = link.getAttribute('href');
160
+ if (href && href.startsWith('#')) {
161
+ linkMap.set(href, link);
162
+ }
163
+ });
164
+
165
+ const observer = new IntersectionObserver((entries) => {
166
+ entries.forEach(entry => {
167
+ if (entry.isIntersecting) {
168
+ const id = entry.target.getAttribute('id');
169
+ const activeLink = linkMap.get(`#${id}`);
170
+
171
+ if (activeLink) {
172
+ // Remove active class from all links
173
+ navLinks.forEach(link => link.classList.remove('active'));
174
+ // Add active class to current link
175
+ activeLink.classList.add('active');
176
+
177
+ // Update URL hash without scrolling
178
+ if (history.replaceState) {
179
+ history.replaceState(null, null, `#${id}`);
180
+ } else {
181
+ window.location.hash = id;
182
+ }
183
+ }
184
+ }
185
+ });
186
+ }, observerOptions);
187
+
188
+ // Observe all sections
189
+ sections.forEach(section => {
190
+ if (section.id) {
191
+ observer.observe(section);
192
+ }
193
+ });
194
+ </script>
195
+ </body>
196
+ </html>
197
+ HTML
198
+ end
199
+
200
+ def wrap_sections(html)
201
+ # Remove the first h1 (title) as it's in the page header
202
+ html = html.sub(/<h1[^>]*>.*?<\/h1>/, '')
203
+
204
+ # Wrap h2 sections
205
+ html.gsub!(/<h2 id="([^"]+)">(.+?)<\/h2>/) do
206
+ id = $1
207
+ title = $2
208
+ if id == 'usage'
209
+ %(</section>\n\n<section id="#{id}" class="section">\n<h2 id="#{id}">#{title}</h2>)
210
+ else
211
+ %(<section id="#{id}" class="section">\n<h2 id="#{id}">#{title}</h2>)
212
+ end
213
+ end
214
+
215
+ # Wrap h3 subsections
216
+ html.gsub!(/<h3 id="([^"]+)">(.+?)<\/h3>/) do
217
+ id = $1
218
+ title = $2
219
+ %(</article>\n\n<article id="#{id}" class="subsection">\n<h3 id="#{id}">#{title}</h3>)
220
+ end
221
+
222
+ # Close any remaining open sections
223
+ html += "\n</article>\n</section>" if html.include?('<section') || html.include?('<article')
224
+
225
+ # Wrap first section (Overview)
226
+ html = "<section id=\"overview\" class=\"section\">\n" + html
227
+
228
+ # Clean up multiple closing tags
229
+ html.gsub!(/(<\/article>\s*){2,}/, '</article>')
230
+ html.gsub!(/(<\/section>\s*){2,}/, '</section>')
231
+
232
+ html
233
+ end
234
+
235
+ end
236
+
237
+ # Run the builder
238
+ if __FILE__ == $0
239
+ readme_path = ARGV[0] || 'README.md'
240
+ output_dir = ARGV[1] || 'website'
241
+
242
+ unless File.exist?(readme_path)
243
+ puts "Error: README file not found at #{readme_path}"
244
+ exit 1
245
+ end
246
+
247
+ builder = DocsBuilder.new(
248
+ readme_path: readme_path,
249
+ output_dir: output_dir
250
+ )
251
+
252
+ builder.build
253
+ end