rapitapir 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.
Files changed (157) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +57 -0
  4. data/CHANGELOG.md +94 -0
  5. data/CLEANUP_SUMMARY.md +155 -0
  6. data/CONTRIBUTING.md +280 -0
  7. data/LICENSE +21 -0
  8. data/README.md +485 -0
  9. data/debug_hash.rb +20 -0
  10. data/docs/EXTENSION_COMPARISON.md +388 -0
  11. data/docs/SINATRA_EXTENSION.md +467 -0
  12. data/docs/archive/PHASE_1_2_COMPLETE.md +77 -0
  13. data/docs/archive/PHASE_1_3_COMPLETE.md +152 -0
  14. data/docs/archive/PHASE_2_1_OBSERVABILITY_COMPLETED.md +203 -0
  15. data/docs/archive/PHASE_2_SUMMARY.md +209 -0
  16. data/docs/archive/REFACTORING_SUMMARY.md +184 -0
  17. data/docs/archive/phase_1_3_plan.md +136 -0
  18. data/docs/archive/sinatra_extension_summary.md +188 -0
  19. data/docs/archive/sinatra_working_solution.md +113 -0
  20. data/docs/archive/typescript-client-generator-summary.md +259 -0
  21. data/docs/auto-derivation.md +146 -0
  22. data/docs/blueprint.md +1091 -0
  23. data/docs/endpoint-definition.md +211 -0
  24. data/docs/github_pages_fix.md +52 -0
  25. data/docs/github_pages_setup.md +49 -0
  26. data/docs/implementation-status.md +357 -0
  27. data/docs/observability.md +647 -0
  28. data/docs/phase3-plan.md +108 -0
  29. data/docs/sinatra_rapitapir.md +87 -0
  30. data/docs/type_shortcuts.md +146 -0
  31. data/examples/README_ENTERPRISE.md +202 -0
  32. data/examples/authentication_example.rb +192 -0
  33. data/examples/auto_derivation_ruby_friendly.rb +163 -0
  34. data/examples/cli/user_api_endpoints.rb +56 -0
  35. data/examples/client/typescript_client_example.rb +102 -0
  36. data/examples/client/user-api-client.ts +193 -0
  37. data/examples/demo_api.rb +41 -0
  38. data/examples/docs/documentation_example.rb +112 -0
  39. data/examples/docs/user-api-docs.html +789 -0
  40. data/examples/docs/user-api-docs.md +403 -0
  41. data/examples/enhanced_auto_derivation_test.rb +83 -0
  42. data/examples/enterprise_extension_demo.rb +417 -0
  43. data/examples/enterprise_rapitapir_api.rb +662 -0
  44. data/examples/getting_started_extension.rb +218 -0
  45. data/examples/hello_world.rb +74 -0
  46. data/examples/oauth2/.env.example +19 -0
  47. data/examples/oauth2/README.md +205 -0
  48. data/examples/oauth2/generic_oauth2_api.rb +226 -0
  49. data/examples/oauth2/get_token.rb +72 -0
  50. data/examples/oauth2/songs_api_with_auth0.rb +320 -0
  51. data/examples/oauth2/test_api.sh +16 -0
  52. data/examples/oauth2/test_songs_api.sh +110 -0
  53. data/examples/observability/.env.example +35 -0
  54. data/examples/observability/README.md +230 -0
  55. data/examples/observability/README_HONEYCOMB.md +332 -0
  56. data/examples/observability/advanced_setup.rb +384 -0
  57. data/examples/observability/basic_setup.rb +192 -0
  58. data/examples/observability/complete_test.rb +121 -0
  59. data/examples/observability/honeycomb_example.rb +523 -0
  60. data/examples/observability/honeycomb_rapitapir_clean.rb +488 -0
  61. data/examples/observability/honeycomb_rapitapir_example.rb +523 -0
  62. data/examples/observability/honeycomb_working_example.rb +489 -0
  63. data/examples/observability/quick_test.rb +78 -0
  64. data/examples/observability/simple_test.rb +14 -0
  65. data/examples/observability/test_honeycomb_demo.rb +354 -0
  66. data/examples/observability/test_live_honeycomb.rb +111 -0
  67. data/examples/observability/test_validation.rb +78 -0
  68. data/examples/observability/test_working_validation.rb +66 -0
  69. data/examples/openapi/user_api_schema.rb +132 -0
  70. data/examples/production_ready_example.rb +105 -0
  71. data/examples/rails/users_controller.rb +146 -0
  72. data/examples/readme/basic_sinatra_example.rb +128 -0
  73. data/examples/server/user_api.rb +179 -0
  74. data/examples/simple_auto_derivation_demo.rb +44 -0
  75. data/examples/simple_demo_api.rb +18 -0
  76. data/examples/sinatra/user_app.rb +127 -0
  77. data/examples/t_shortcut_demo.rb +59 -0
  78. data/examples/user_api.rb +190 -0
  79. data/examples/working_getting_started.rb +184 -0
  80. data/examples/working_simple_example.rb +195 -0
  81. data/lib/rapitapir/auth/configuration.rb +129 -0
  82. data/lib/rapitapir/auth/context.rb +122 -0
  83. data/lib/rapitapir/auth/errors.rb +104 -0
  84. data/lib/rapitapir/auth/middleware.rb +324 -0
  85. data/lib/rapitapir/auth/oauth2.rb +350 -0
  86. data/lib/rapitapir/auth/schemes.rb +420 -0
  87. data/lib/rapitapir/auth.rb +113 -0
  88. data/lib/rapitapir/cli/command.rb +535 -0
  89. data/lib/rapitapir/cli/server.rb +243 -0
  90. data/lib/rapitapir/cli/validator.rb +373 -0
  91. data/lib/rapitapir/client/generator_base.rb +272 -0
  92. data/lib/rapitapir/client/typescript_generator.rb +350 -0
  93. data/lib/rapitapir/core/endpoint.rb +158 -0
  94. data/lib/rapitapir/core/enhanced_endpoint.rb +235 -0
  95. data/lib/rapitapir/core/input.rb +182 -0
  96. data/lib/rapitapir/core/output.rb +164 -0
  97. data/lib/rapitapir/core/request.rb +19 -0
  98. data/lib/rapitapir/core/response.rb +17 -0
  99. data/lib/rapitapir/docs/html_generator.rb +780 -0
  100. data/lib/rapitapir/docs/markdown_generator.rb +464 -0
  101. data/lib/rapitapir/dsl/endpoint_dsl.rb +116 -0
  102. data/lib/rapitapir/dsl/enhanced_endpoint_dsl.rb +62 -0
  103. data/lib/rapitapir/dsl/enhanced_input.rb +73 -0
  104. data/lib/rapitapir/dsl/enhanced_output.rb +63 -0
  105. data/lib/rapitapir/dsl/enhanced_structures.rb +393 -0
  106. data/lib/rapitapir/dsl/fluent_dsl.rb +72 -0
  107. data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +316 -0
  108. data/lib/rapitapir/dsl/http_verbs.rb +77 -0
  109. data/lib/rapitapir/dsl/input_methods.rb +47 -0
  110. data/lib/rapitapir/dsl/observability_methods.rb +81 -0
  111. data/lib/rapitapir/dsl/output_methods.rb +43 -0
  112. data/lib/rapitapir/dsl/type_resolution.rb +43 -0
  113. data/lib/rapitapir/observability/configuration.rb +108 -0
  114. data/lib/rapitapir/observability/health_check.rb +236 -0
  115. data/lib/rapitapir/observability/logging.rb +270 -0
  116. data/lib/rapitapir/observability/metrics.rb +203 -0
  117. data/lib/rapitapir/observability/middleware.rb +243 -0
  118. data/lib/rapitapir/observability/tracing.rb +143 -0
  119. data/lib/rapitapir/observability.rb +28 -0
  120. data/lib/rapitapir/openapi/schema_generator.rb +403 -0
  121. data/lib/rapitapir/schema.rb +136 -0
  122. data/lib/rapitapir/server/enhanced_rack_adapter.rb +379 -0
  123. data/lib/rapitapir/server/middleware.rb +120 -0
  124. data/lib/rapitapir/server/path_matcher.rb +45 -0
  125. data/lib/rapitapir/server/rack_adapter.rb +215 -0
  126. data/lib/rapitapir/server/rails_adapter.rb +17 -0
  127. data/lib/rapitapir/server/rails_adapter_class.rb +53 -0
  128. data/lib/rapitapir/server/rails_controller.rb +72 -0
  129. data/lib/rapitapir/server/rails_input_processor.rb +73 -0
  130. data/lib/rapitapir/server/rails_response_handler.rb +29 -0
  131. data/lib/rapitapir/server/sinatra_adapter.rb +200 -0
  132. data/lib/rapitapir/server/sinatra_integration.rb +93 -0
  133. data/lib/rapitapir/sinatra/configuration.rb +91 -0
  134. data/lib/rapitapir/sinatra/extension.rb +214 -0
  135. data/lib/rapitapir/sinatra/oauth2_helpers.rb +236 -0
  136. data/lib/rapitapir/sinatra/resource_builder.rb +152 -0
  137. data/lib/rapitapir/sinatra/swagger_ui_generator.rb +166 -0
  138. data/lib/rapitapir/sinatra_rapitapir.rb +40 -0
  139. data/lib/rapitapir/types/array.rb +163 -0
  140. data/lib/rapitapir/types/auto_derivation.rb +265 -0
  141. data/lib/rapitapir/types/base.rb +146 -0
  142. data/lib/rapitapir/types/boolean.rb +46 -0
  143. data/lib/rapitapir/types/date.rb +92 -0
  144. data/lib/rapitapir/types/datetime.rb +98 -0
  145. data/lib/rapitapir/types/email.rb +32 -0
  146. data/lib/rapitapir/types/float.rb +134 -0
  147. data/lib/rapitapir/types/hash.rb +161 -0
  148. data/lib/rapitapir/types/integer.rb +143 -0
  149. data/lib/rapitapir/types/object.rb +156 -0
  150. data/lib/rapitapir/types/optional.rb +65 -0
  151. data/lib/rapitapir/types/string.rb +185 -0
  152. data/lib/rapitapir/types/uuid.rb +32 -0
  153. data/lib/rapitapir/types.rb +155 -0
  154. data/lib/rapitapir/version.rb +5 -0
  155. data/lib/rapitapir.rb +173 -0
  156. data/rapitapir.gemspec +66 -0
  157. metadata +387 -0
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra'
4
+ require_relative '../../lib/rapitapir'
5
+ require 'dotenv/load'
6
+
7
+ # Example Sinatra API with generic OAuth2 token introspection
8
+ # Demonstrates OAuth2 integration without Auth0-specific features
9
+ class GenericOAuth2API < SinatraRapiTapir
10
+ rapitapir do
11
+ info(
12
+ title: 'Generic OAuth2 API',
13
+ version: '1.0.0',
14
+ description: 'Example API using OAuth2 token introspection'
15
+ )
16
+
17
+ development_defaults!
18
+ enable_docs
19
+ end
20
+
21
+ # Configure OAuth2 with Auth0 (preferred) or generic introspection
22
+ # For Auth0 testing, set AUTH0_DOMAIN and AUTH0_AUDIENCE
23
+ # For generic OAuth2, set OAUTH2_INTROSPECTION_ENDPOINT, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET
24
+
25
+ if ENV['AUTH0_DOMAIN'] && ENV['AUTH0_AUDIENCE']
26
+ # Use Auth0 JWT validation
27
+ auth0_oauth2(
28
+ domain: ENV['AUTH0_DOMAIN'],
29
+ audience: ENV['AUTH0_AUDIENCE']
30
+ )
31
+ puts "๐Ÿ”’ Configured Auth0 OAuth2: #{ENV['AUTH0_DOMAIN']}"
32
+ elsif ENV['OAUTH2_INTROSPECTION_ENDPOINT']
33
+ # Use generic OAuth2 introspection
34
+ oauth2_introspection(
35
+ introspection_endpoint: ENV['OAUTH2_INTROSPECTION_ENDPOINT'],
36
+ client_id: ENV['OAUTH2_CLIENT_ID'],
37
+ client_secret: ENV['OAUTH2_CLIENT_SECRET']
38
+ )
39
+ puts "๐Ÿ”’ Configured Generic OAuth2: #{ENV['OAUTH2_INTROSPECTION_ENDPOINT']}"
40
+ else
41
+ puts "โš ๏ธ No OAuth2 configuration found. Set AUTH0_DOMAIN+AUTH0_AUDIENCE or OAUTH2_* environment variables."
42
+ end
43
+
44
+ # Manually include OAuth2 helper methods to ensure they're available
45
+ helpers RapiTapir::Sinatra::OAuth2HelperMethods
46
+
47
+ # Debug: Check what methods are available
48
+ puts "๐Ÿ” Available methods: #{self.methods.grep(/oauth/).join(', ')}"
49
+
50
+ # Protect only POST routes (before filters in Sinatra apply to all methods by default)
51
+ before '/tasks' do
52
+ if request.post?
53
+ authorize_oauth2!(required_scopes: ['write:tasks'])
54
+ end
55
+ end
56
+
57
+ # Simple data model
58
+ TASKS = [
59
+ { id: 1, title: 'Learn OAuth2', completed: false },
60
+ { id: 2, title: 'Implement API', completed: true },
61
+ { id: 3, title: 'Write tests', completed: false }
62
+ ]
63
+
64
+ # Schemas
65
+ TASK_SCHEMA = T.hash({
66
+ 'id' => T.integer(minimum: 1),
67
+ 'title' => T.string(min_length: 1, max_length: 200),
68
+ 'completed' => T.boolean
69
+ })
70
+
71
+ CREATE_TASK_SCHEMA = T.hash({
72
+ 'title' => T.string(min_length: 1, max_length: 200),
73
+ 'completed' => T.optional(T.boolean)
74
+ })
75
+
76
+ ERROR_SCHEMA = T.hash({
77
+ 'error' => T.string,
78
+ 'error_description' => T.optional(T.string)
79
+ })
80
+
81
+ # Public endpoint
82
+ endpoint(
83
+ GET('/tasks')
84
+ .summary('List tasks')
85
+ .description('Get all tasks (public endpoint)')
86
+ .ok(T.array(TASK_SCHEMA))
87
+ .tags('tasks')
88
+ .build
89
+ ) do
90
+ TASKS.to_json
91
+ end
92
+
93
+ # Protected endpoints with different scope requirements
94
+ endpoint(
95
+ POST('/tasks')
96
+ .summary('Create task')
97
+ .description('Create a new task (requires write scope)')
98
+ .body(CREATE_TASK_SCHEMA)
99
+ .created(TASK_SCHEMA)
100
+ .error_response(401, ERROR_SCHEMA)
101
+ .error_response(403, ERROR_SCHEMA)
102
+ .tags('tasks')
103
+ .build
104
+ ) do |inputs|
105
+ # Authentication handled by route protection above
106
+
107
+ new_task = {
108
+ id: TASKS.map { |t| t[:id] }.max + 1,
109
+ title: inputs[:title],
110
+ completed: inputs[:completed] || false
111
+ }
112
+
113
+ TASKS << new_task # Add to our in-memory store
114
+ new_task.to_json
115
+ end
116
+
117
+ endpoint(
118
+ PUT('/tasks/:id')
119
+ .summary('Update task')
120
+ .description('Update a task (requires write scope)')
121
+ .path_param(:id, T.integer(minimum: 1))
122
+ .body(CREATE_TASK_SCHEMA)
123
+ .ok(TASK_SCHEMA)
124
+ .error_response(401, ERROR_SCHEMA)
125
+ .error_response(403, ERROR_SCHEMA)
126
+ .error_response(404, ERROR_SCHEMA)
127
+ .tags('tasks')
128
+ .build
129
+ ) do |inputs|
130
+ authorize_oauth2!(required_scopes: ['write:tasks'])
131
+
132
+ task = TASKS.find { |t| t[:id] == inputs[:id] }
133
+ halt 404, { error: 'not_found' }.to_json unless task
134
+
135
+ task[:title] = inputs[:title] if inputs[:title]
136
+ task[:completed] = inputs[:completed] unless inputs[:completed].nil?
137
+
138
+ task.to_json
139
+ end
140
+
141
+ endpoint(
142
+ DELETE('/tasks/:id')
143
+ .summary('Delete task')
144
+ .description('Delete a task (requires admin scope)')
145
+ .path_param(:id, T.integer(minimum: 1))
146
+ .ok(TASK_SCHEMA)
147
+ .error_response(401, ERROR_SCHEMA)
148
+ .error_response(403, ERROR_SCHEMA)
149
+ .error_response(404, ERROR_SCHEMA)
150
+ .tags('tasks')
151
+ .build
152
+ ) do |inputs|
153
+ authorize_oauth2!(required_scopes: ['write:tasks'])
154
+
155
+ # Check for admin scope for delete operations
156
+ auth_context = request.env['rapitapir.auth.context']
157
+ unless auth_context && auth_context.scopes.include?('admin:tasks')
158
+ halt 403, {
159
+ error: 'insufficient_scope',
160
+ error_description: 'Admin scope required for delete operations'
161
+ }.to_json
162
+ end
163
+
164
+ task = TASKS.find { |t| t[:id] == inputs[:id] }
165
+ halt 404, { error: 'not_found' }.to_json unless task
166
+
167
+ task.to_json
168
+ end
169
+
170
+ # User info endpoint
171
+ endpoint(
172
+ GET('/me')
173
+ .summary('Get user info')
174
+ .description('Get current user information')
175
+ .ok(T.hash({
176
+ 'user' => T.hash({
177
+ 'id' => T.string,
178
+ 'username' => T.optional(T.string),
179
+ 'email' => T.optional(T.string)
180
+ }),
181
+ 'scopes' => T.array(T.string),
182
+ 'client_id' => T.string
183
+ }))
184
+ .error_response(401, ERROR_SCHEMA)
185
+ .tags('user')
186
+ .build
187
+ ) do
188
+ context = authorize_oauth2!
189
+
190
+ {
191
+ user: context.user,
192
+ scopes: context.scopes,
193
+ client_id: context.metadata[:client_id] || 'unknown'
194
+ }.to_json
195
+ end
196
+
197
+ # Health check with authentication status
198
+ get '/health' do
199
+ content_type :json
200
+
201
+ auth_context = request.env['rapitapir.auth.context']
202
+ auth_status = if auth_context
203
+ {
204
+ authenticated: true,
205
+ user_id: auth_context.user[:id],
206
+ scopes: auth_context.scopes
207
+ }
208
+ else
209
+ { authenticated: false }
210
+ end
211
+
212
+ {
213
+ status: 'healthy',
214
+ timestamp: Time.now.iso8601,
215
+ auth: auth_status
216
+ }.to_json
217
+ end
218
+ end
219
+
220
+ if __FILE__ == $0
221
+ puts "๐Ÿš€ Starting Generic OAuth2 API..."
222
+ puts "๐Ÿ“– Documentation: http://localhost:4567/docs"
223
+ puts "โค๏ธ Health check: http://localhost:4567/health"
224
+
225
+ GenericOAuth2API.run!
226
+ end
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'dotenv/load'
8
+
9
+ # Simple script to get an Auth0 access token for API testing
10
+ # Requires AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_AUDIENCE
11
+
12
+ def get_auth0_token
13
+ domain = ENV['AUTH0_DOMAIN']
14
+ client_id = ENV['AUTH0_CLIENT_ID']
15
+ client_secret = ENV['AUTH0_CLIENT_SECRET']
16
+ audience = ENV['AUTH0_AUDIENCE']
17
+
18
+ unless domain && client_id && client_secret && audience
19
+ puts "โŒ Missing environment variables. Required:"
20
+ puts " AUTH0_DOMAIN"
21
+ puts " AUTH0_CLIENT_ID"
22
+ puts " AUTH0_CLIENT_SECRET"
23
+ puts " AUTH0_AUDIENCE"
24
+ puts "\nCopy .env.example to .env and fill in your Auth0 details."
25
+ exit 1
26
+ end
27
+
28
+ uri = URI("https://#{domain}/oauth/token")
29
+
30
+ payload = {
31
+ client_id: client_id,
32
+ client_secret: client_secret,
33
+ audience: audience,
34
+ grant_type: 'client_credentials'
35
+ }
36
+
37
+ puts "๐Ÿ”„ Requesting token from Auth0..."
38
+ puts " Domain: #{domain}"
39
+ puts " Audience: #{audience}"
40
+ puts " Client ID: #{client_id[0..8]}..."
41
+
42
+ response = Net::HTTP.post(uri, payload.to_json, {
43
+ 'Content-Type' => 'application/json'
44
+ })
45
+
46
+ if response.code == '200'
47
+ token_data = JSON.parse(response.body)
48
+ access_token = token_data['access_token']
49
+ expires_in = token_data['expires_in']
50
+
51
+ puts "โœ… Token obtained successfully!"
52
+ puts " Expires in: #{expires_in} seconds"
53
+ puts " Token (first 50 chars): #{access_token[0..49]}..."
54
+ puts
55
+ puts "๐Ÿงช Test the API with:"
56
+ puts " curl -H \"Authorization: Bearer #{access_token}\" http://localhost:4567/me"
57
+ puts
58
+ puts "๐Ÿ“‹ Full token:"
59
+ puts access_token
60
+
61
+ return access_token
62
+ else
63
+ puts "โŒ Failed to get token:"
64
+ puts " Status: #{response.code}"
65
+ puts " Body: #{response.body}"
66
+ exit 1
67
+ end
68
+ end
69
+
70
+ if __FILE__ == $0
71
+ get_auth0_token
72
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra'
4
+ require_relative '../../lib/rapitapir'
5
+ require 'dotenv/load' # For loading environment variables
6
+
7
+ # Example Sinatra API with Auth0 OAuth2 integration
8
+ # Based on the Auth0 blog post patterns but using RapiTapir
9
+ class SongsAPIWithAuth0 < SinatraRapiTapir
10
+ # Configure RapiTapir with OAuth2 authentication
11
+ rapitapir do
12
+ info(
13
+ title: 'Songs API with Auth0',
14
+ version: '1.0.0',
15
+ description: 'A secure Sinatra API using Auth0 for authentication'
16
+ )
17
+
18
+ # Development defaults (CORS, health checks, etc.)
19
+ development_defaults!
20
+
21
+ # Enable documentation
22
+ enable_docs
23
+ end
24
+
25
+ # Configure Auth0 OAuth2 authentication
26
+ # Environment variables should be set:
27
+ # AUTH0_DOMAIN=your-domain.auth0.com
28
+ # AUTH0_AUDIENCE=https://your-api-identifier
29
+ auth0_oauth2(
30
+ domain: ENV['AUTH0_DOMAIN'],
31
+ audience: ENV['AUTH0_AUDIENCE'],
32
+ algorithm: 'RS256' # Auth0 default
33
+ )
34
+
35
+ puts "๐Ÿ”’ Configured Auth0 OAuth2: #{ENV['AUTH0_DOMAIN']}"
36
+
37
+ # Manually include OAuth2 helper methods to ensure they're available
38
+ helpers RapiTapir::Sinatra::OAuth2HelperMethods
39
+
40
+ # Debug: Check what methods are available
41
+ puts "๐Ÿ” Available methods: #{self.methods.grep(/oauth/).join(', ')}"
42
+
43
+ # Protect endpoints that require authentication
44
+ before '/songs' do
45
+ # Only protect POST requests (creation)
46
+ if request.post?
47
+ authorize_oauth2!(required_scopes: ['write:tasks'])
48
+ end
49
+ end
50
+
51
+ before '/songs/*' do
52
+ # Protect PUT and DELETE requests (update/delete operations)
53
+ if request.put?
54
+ authorize_oauth2!(required_scopes: ['write:tasks'])
55
+ elsif request.delete?
56
+ authorize_oauth2!(required_scopes: ['admin:tasks'])
57
+ end
58
+ end
59
+
60
+ # Song model (simple in-memory storage for demo)
61
+ class Song
62
+ attr_accessor :id, :name, :url
63
+
64
+ def initialize(id, name, url)
65
+ @id = id
66
+ @name = name
67
+ @url = url
68
+ end
69
+
70
+ def to_json(*_args)
71
+ {
72
+ 'id' => id,
73
+ 'name' => name,
74
+ 'url' => url
75
+ }.to_json
76
+ end
77
+ end
78
+
79
+ # Sample data
80
+ SONGS = [
81
+ Song.new(1, 'My Way', 'https://www.last.fm/music/Frank+Sinatra/_/My+Way'),
82
+ Song.new(2, 'Strangers in the Night', 'https://www.last.fm/music/Frank+Sinatra/_/Strangers+in+the+Night'),
83
+ Song.new(3, 'Fly Me to the Moon', 'https://www.last.fm/music/Frank+Sinatra/_/Fly+Me+to+the+Moon')
84
+ ]
85
+
86
+ # Define schemas using RapiTapir's type system
87
+ SONG_SCHEMA = T.hash({
88
+ 'id' => T.integer(minimum: 1),
89
+ 'name' => T.string(min_length: 1, max_length: 200),
90
+ 'url' => T.string(format: :url)
91
+ })
92
+
93
+ CREATE_SONG_SCHEMA = T.hash({
94
+ 'name' => T.string(min_length: 1, max_length: 200),
95
+ 'url' => T.string(format: :url)
96
+ })
97
+
98
+ ERROR_SCHEMA = T.hash({
99
+ 'error' => T.string,
100
+ 'error_description' => T.optional(T.string)
101
+ })
102
+
103
+ # Public endpoints (no authentication required)
104
+
105
+ # GET /songs - List all songs (public)
106
+ endpoint(
107
+ GET('/songs')
108
+ .summary('List all songs')
109
+ .description('Retrieve a list of all Frank Sinatra songs')
110
+ .ok(T.array(SONG_SCHEMA))
111
+ .tags('songs')
112
+ .build
113
+ ) do
114
+ SONGS.to_json
115
+ end
116
+
117
+ # GET /songs/:id - Get song by ID (public)
118
+ endpoint(
119
+ GET('/songs/:id')
120
+ .summary('Get song by ID')
121
+ .description('Retrieve a specific song by its ID')
122
+ .path_param(:id, T.integer(minimum: 1))
123
+ .ok(SONG_SCHEMA)
124
+ .error_response(404, ERROR_SCHEMA, description: 'Song not found')
125
+ .tags('songs')
126
+ .build
127
+ ) do |inputs|
128
+ song = SONGS.find { |s| s.id == inputs[:id] }
129
+
130
+ unless song
131
+ halt 404, {
132
+ error: 'not_found',
133
+ error_description: 'Song not found'
134
+ }.to_json
135
+ end
136
+
137
+ song.to_json
138
+ end
139
+
140
+ # Protected endpoints (OAuth2 authentication required)
141
+
142
+ # POST /songs - Create new song (requires authentication)
143
+ endpoint(
144
+ POST('/songs')
145
+ .summary('Create a new song')
146
+ .description('Create a new song entry (requires authentication)')
147
+ .body(CREATE_SONG_SCHEMA)
148
+ .created(SONG_SCHEMA)
149
+ .error_response(400, ERROR_SCHEMA, description: 'Invalid input')
150
+ .error_response(401, ERROR_SCHEMA, description: 'Authentication required')
151
+ .error_response(403, ERROR_SCHEMA, description: 'Insufficient permissions')
152
+ .tags('songs')
153
+ .build
154
+ ) do |inputs|
155
+ # Authentication handled by before filter
156
+
157
+ # Extract body data from inputs
158
+ body_data = inputs[:body]
159
+
160
+ # Create new song
161
+ new_id = SONGS.map(&:id).max + 1
162
+ new_song = Song.new(new_id, body_data['name'], body_data['url'])
163
+
164
+ # Add to our in-memory store
165
+ SONGS << new_song
166
+
167
+ new_song.to_json
168
+ end
169
+
170
+ # PUT /songs/:id - Update song (requires authentication)
171
+ endpoint(
172
+ PUT('/songs/:id')
173
+ .summary('Update a song')
174
+ .description('Update an existing song (requires authentication)')
175
+ .path_param(:id, T.integer(minimum: 1))
176
+ .body(CREATE_SONG_SCHEMA)
177
+ .ok(SONG_SCHEMA)
178
+ .error_response(400, ERROR_SCHEMA, description: 'Invalid input')
179
+ .error_response(401, ERROR_SCHEMA, description: 'Authentication required')
180
+ .error_response(403, ERROR_SCHEMA, description: 'Insufficient permissions')
181
+ .error_response(404, ERROR_SCHEMA, description: 'Song not found')
182
+ .tags('songs')
183
+ .build
184
+ ) do |inputs|
185
+ # Authentication handled by before filter
186
+
187
+ song = SONGS.find { |s| s.id == inputs[:id] }
188
+
189
+ unless song
190
+ halt 404, {
191
+ error: 'not_found',
192
+ error_description: 'Song not found'
193
+ }.to_json
194
+ end
195
+
196
+ # Extract body data from inputs
197
+ body_data = inputs[:body]
198
+
199
+ # Update song properties
200
+ song.name = body_data['name'] if body_data['name']
201
+ song.url = body_data['url'] if body_data['url']
202
+
203
+ song.to_json
204
+ end
205
+
206
+ # DELETE /songs/:id - Delete song (requires authentication with admin scope)
207
+ endpoint(
208
+ DELETE('/songs/:id')
209
+ .summary('Delete a song')
210
+ .description('Delete a song (requires admin permissions)')
211
+ .path_param(:id, T.integer(minimum: 1))
212
+ .ok(SONG_SCHEMA)
213
+ .error_response(401, ERROR_SCHEMA, description: 'Authentication required')
214
+ .error_response(403, ERROR_SCHEMA, description: 'Admin permissions required')
215
+ .error_response(404, ERROR_SCHEMA, description: 'Song not found')
216
+ .tags('songs')
217
+ .build
218
+ ) do |inputs|
219
+ # Authentication handled by before filter
220
+
221
+ song = SONGS.find { |s| s.id == inputs[:id] }
222
+
223
+ unless song
224
+ halt 404, {
225
+ error: 'not_found',
226
+ error_description: 'Song not found'
227
+ }.to_json
228
+ end
229
+
230
+ # In a real app, you'd delete from database
231
+ # For demo, we'll remove from array and return the song
232
+ SONGS.delete(song)
233
+ song.to_json
234
+ end
235
+
236
+ # GET /me - Get current user info (requires authentication)
237
+ # Using regular Sinatra route since RapiTapir endpoints have different context
238
+ get '/me' do
239
+ # Authenticate user
240
+ context = authorize_oauth2!
241
+
242
+ content_type :json
243
+ {
244
+ user: context.user,
245
+ scopes: context.scopes,
246
+ token_info: {
247
+ issuer: context.metadata[:issuer],
248
+ subject: context.metadata[:subject],
249
+ audience: context.metadata[:audience],
250
+ expires_at: context.metadata[:expires_at]&.iso8601
251
+ }
252
+ }.to_json
253
+ end
254
+
255
+ # Error handlers
256
+ error RapiTapir::Auth::InvalidTokenError do
257
+ {
258
+ error: 'invalid_token',
259
+ error_description: env['sinatra.error'].message
260
+ }.to_json
261
+ end
262
+
263
+ error RapiTapir::Auth::AuthenticationError do
264
+ {
265
+ error: 'authentication_failed',
266
+ error_description: env['sinatra.error'].message
267
+ }.to_json
268
+ end
269
+
270
+ # Helper methods for testing and demonstrations
271
+ helpers do
272
+ # Method to generate a test token (for development/testing only)
273
+ def generate_test_instructions
274
+ {
275
+ message: 'To test protected endpoints, you need a valid Auth0 access token',
276
+ instructions: [
277
+ '1. Set up your Auth0 application and API',
278
+ '2. Get an access token using the Auth0 Dashboard test feature',
279
+ '3. Send requests with: Authorization: Bearer YOUR_TOKEN',
280
+ '4. Ensure your token has the required scopes'
281
+ ],
282
+ required_scopes: {
283
+ 'create:songs' => 'Required to create new songs',
284
+ 'update:songs' => 'Required to update existing songs',
285
+ 'delete:songs' => 'Required to delete songs',
286
+ 'admin' => 'Required for administrative operations'
287
+ },
288
+ example_curl: 'curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:4567/me'
289
+ }
290
+ end
291
+ end
292
+
293
+ # Development helper endpoint (remove in production)
294
+ get '/auth-info' do
295
+ content_type :json
296
+ generate_test_instructions.to_json
297
+ end
298
+ end
299
+
300
+ # Run the application if this file is executed directly
301
+ if __FILE__ == $0
302
+ # Ensure required environment variables are set
303
+ required_env_vars = %w[AUTH0_DOMAIN AUTH0_AUDIENCE]
304
+ missing_vars = required_env_vars.reject { |var| ENV[var] }
305
+
306
+ if missing_vars.any?
307
+ puts "โŒ Missing required environment variables: #{missing_vars.join(', ')}"
308
+ puts "\n๐Ÿ“ Create a .env file with:"
309
+ puts "AUTH0_DOMAIN=your-domain.auth0.com"
310
+ puts "AUTH0_AUDIENCE=https://your-api-identifier"
311
+ exit 1
312
+ end
313
+
314
+ puts "๐Ÿš€ Starting Songs API with Auth0 OAuth2..."
315
+ puts "๐Ÿ“– Documentation available at: http://localhost:4567/docs"
316
+ puts "๐Ÿ” Auth info available at: http://localhost:4567/auth-info"
317
+ puts "๐ŸŽต Public songs endpoint: http://localhost:4567/songs"
318
+
319
+ SongsAPIWithAuth0.run!
320
+ end
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ TOKEN=""
3
+
4
+ echo "๐Ÿงช Testing OAuth2 API with real Auth0 token..."
5
+
6
+ echo "1. Testing public endpoint (GET /tasks):"
7
+ curl -s http://localhost:4567/tasks | jq .
8
+
9
+ echo -e "\n2. Testing protected endpoint without token:"
10
+ curl -s -X POST http://localhost:4567/tasks -H "Content-Type: application/json" -d '{"title":"Test"}' | jq .
11
+
12
+ echo -e "\n3. Testing protected endpoint WITH valid token:"
13
+ curl -s -X POST http://localhost:4567/tasks -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"title":"Auth0 Success!","completed":false}' | jq .
14
+
15
+ echo -e "\n4. Testing tasks list after creation:"
16
+ curl -s http://localhost:4567/tasks | jq .