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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +94 -0
- data/CLEANUP_SUMMARY.md +155 -0
- data/CONTRIBUTING.md +280 -0
- data/LICENSE +21 -0
- data/README.md +485 -0
- data/debug_hash.rb +20 -0
- data/docs/EXTENSION_COMPARISON.md +388 -0
- data/docs/SINATRA_EXTENSION.md +467 -0
- data/docs/archive/PHASE_1_2_COMPLETE.md +77 -0
- data/docs/archive/PHASE_1_3_COMPLETE.md +152 -0
- data/docs/archive/PHASE_2_1_OBSERVABILITY_COMPLETED.md +203 -0
- data/docs/archive/PHASE_2_SUMMARY.md +209 -0
- data/docs/archive/REFACTORING_SUMMARY.md +184 -0
- data/docs/archive/phase_1_3_plan.md +136 -0
- data/docs/archive/sinatra_extension_summary.md +188 -0
- data/docs/archive/sinatra_working_solution.md +113 -0
- data/docs/archive/typescript-client-generator-summary.md +259 -0
- data/docs/auto-derivation.md +146 -0
- data/docs/blueprint.md +1091 -0
- data/docs/endpoint-definition.md +211 -0
- data/docs/github_pages_fix.md +52 -0
- data/docs/github_pages_setup.md +49 -0
- data/docs/implementation-status.md +357 -0
- data/docs/observability.md +647 -0
- data/docs/phase3-plan.md +108 -0
- data/docs/sinatra_rapitapir.md +87 -0
- data/docs/type_shortcuts.md +146 -0
- data/examples/README_ENTERPRISE.md +202 -0
- data/examples/authentication_example.rb +192 -0
- data/examples/auto_derivation_ruby_friendly.rb +163 -0
- data/examples/cli/user_api_endpoints.rb +56 -0
- data/examples/client/typescript_client_example.rb +102 -0
- data/examples/client/user-api-client.ts +193 -0
- data/examples/demo_api.rb +41 -0
- data/examples/docs/documentation_example.rb +112 -0
- data/examples/docs/user-api-docs.html +789 -0
- data/examples/docs/user-api-docs.md +403 -0
- data/examples/enhanced_auto_derivation_test.rb +83 -0
- data/examples/enterprise_extension_demo.rb +417 -0
- data/examples/enterprise_rapitapir_api.rb +662 -0
- data/examples/getting_started_extension.rb +218 -0
- data/examples/hello_world.rb +74 -0
- data/examples/oauth2/.env.example +19 -0
- data/examples/oauth2/README.md +205 -0
- data/examples/oauth2/generic_oauth2_api.rb +226 -0
- data/examples/oauth2/get_token.rb +72 -0
- data/examples/oauth2/songs_api_with_auth0.rb +320 -0
- data/examples/oauth2/test_api.sh +16 -0
- data/examples/oauth2/test_songs_api.sh +110 -0
- data/examples/observability/.env.example +35 -0
- data/examples/observability/README.md +230 -0
- data/examples/observability/README_HONEYCOMB.md +332 -0
- data/examples/observability/advanced_setup.rb +384 -0
- data/examples/observability/basic_setup.rb +192 -0
- data/examples/observability/complete_test.rb +121 -0
- data/examples/observability/honeycomb_example.rb +523 -0
- data/examples/observability/honeycomb_rapitapir_clean.rb +488 -0
- data/examples/observability/honeycomb_rapitapir_example.rb +523 -0
- data/examples/observability/honeycomb_working_example.rb +489 -0
- data/examples/observability/quick_test.rb +78 -0
- data/examples/observability/simple_test.rb +14 -0
- data/examples/observability/test_honeycomb_demo.rb +354 -0
- data/examples/observability/test_live_honeycomb.rb +111 -0
- data/examples/observability/test_validation.rb +78 -0
- data/examples/observability/test_working_validation.rb +66 -0
- data/examples/openapi/user_api_schema.rb +132 -0
- data/examples/production_ready_example.rb +105 -0
- data/examples/rails/users_controller.rb +146 -0
- data/examples/readme/basic_sinatra_example.rb +128 -0
- data/examples/server/user_api.rb +179 -0
- data/examples/simple_auto_derivation_demo.rb +44 -0
- data/examples/simple_demo_api.rb +18 -0
- data/examples/sinatra/user_app.rb +127 -0
- data/examples/t_shortcut_demo.rb +59 -0
- data/examples/user_api.rb +190 -0
- data/examples/working_getting_started.rb +184 -0
- data/examples/working_simple_example.rb +195 -0
- data/lib/rapitapir/auth/configuration.rb +129 -0
- data/lib/rapitapir/auth/context.rb +122 -0
- data/lib/rapitapir/auth/errors.rb +104 -0
- data/lib/rapitapir/auth/middleware.rb +324 -0
- data/lib/rapitapir/auth/oauth2.rb +350 -0
- data/lib/rapitapir/auth/schemes.rb +420 -0
- data/lib/rapitapir/auth.rb +113 -0
- data/lib/rapitapir/cli/command.rb +535 -0
- data/lib/rapitapir/cli/server.rb +243 -0
- data/lib/rapitapir/cli/validator.rb +373 -0
- data/lib/rapitapir/client/generator_base.rb +272 -0
- data/lib/rapitapir/client/typescript_generator.rb +350 -0
- data/lib/rapitapir/core/endpoint.rb +158 -0
- data/lib/rapitapir/core/enhanced_endpoint.rb +235 -0
- data/lib/rapitapir/core/input.rb +182 -0
- data/lib/rapitapir/core/output.rb +164 -0
- data/lib/rapitapir/core/request.rb +19 -0
- data/lib/rapitapir/core/response.rb +17 -0
- data/lib/rapitapir/docs/html_generator.rb +780 -0
- data/lib/rapitapir/docs/markdown_generator.rb +464 -0
- data/lib/rapitapir/dsl/endpoint_dsl.rb +116 -0
- data/lib/rapitapir/dsl/enhanced_endpoint_dsl.rb +62 -0
- data/lib/rapitapir/dsl/enhanced_input.rb +73 -0
- data/lib/rapitapir/dsl/enhanced_output.rb +63 -0
- data/lib/rapitapir/dsl/enhanced_structures.rb +393 -0
- data/lib/rapitapir/dsl/fluent_dsl.rb +72 -0
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +316 -0
- data/lib/rapitapir/dsl/http_verbs.rb +77 -0
- data/lib/rapitapir/dsl/input_methods.rb +47 -0
- data/lib/rapitapir/dsl/observability_methods.rb +81 -0
- data/lib/rapitapir/dsl/output_methods.rb +43 -0
- data/lib/rapitapir/dsl/type_resolution.rb +43 -0
- data/lib/rapitapir/observability/configuration.rb +108 -0
- data/lib/rapitapir/observability/health_check.rb +236 -0
- data/lib/rapitapir/observability/logging.rb +270 -0
- data/lib/rapitapir/observability/metrics.rb +203 -0
- data/lib/rapitapir/observability/middleware.rb +243 -0
- data/lib/rapitapir/observability/tracing.rb +143 -0
- data/lib/rapitapir/observability.rb +28 -0
- data/lib/rapitapir/openapi/schema_generator.rb +403 -0
- data/lib/rapitapir/schema.rb +136 -0
- data/lib/rapitapir/server/enhanced_rack_adapter.rb +379 -0
- data/lib/rapitapir/server/middleware.rb +120 -0
- data/lib/rapitapir/server/path_matcher.rb +45 -0
- data/lib/rapitapir/server/rack_adapter.rb +215 -0
- data/lib/rapitapir/server/rails_adapter.rb +17 -0
- data/lib/rapitapir/server/rails_adapter_class.rb +53 -0
- data/lib/rapitapir/server/rails_controller.rb +72 -0
- data/lib/rapitapir/server/rails_input_processor.rb +73 -0
- data/lib/rapitapir/server/rails_response_handler.rb +29 -0
- data/lib/rapitapir/server/sinatra_adapter.rb +200 -0
- data/lib/rapitapir/server/sinatra_integration.rb +93 -0
- data/lib/rapitapir/sinatra/configuration.rb +91 -0
- data/lib/rapitapir/sinatra/extension.rb +214 -0
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +236 -0
- data/lib/rapitapir/sinatra/resource_builder.rb +152 -0
- data/lib/rapitapir/sinatra/swagger_ui_generator.rb +166 -0
- data/lib/rapitapir/sinatra_rapitapir.rb +40 -0
- data/lib/rapitapir/types/array.rb +163 -0
- data/lib/rapitapir/types/auto_derivation.rb +265 -0
- data/lib/rapitapir/types/base.rb +146 -0
- data/lib/rapitapir/types/boolean.rb +46 -0
- data/lib/rapitapir/types/date.rb +92 -0
- data/lib/rapitapir/types/datetime.rb +98 -0
- data/lib/rapitapir/types/email.rb +32 -0
- data/lib/rapitapir/types/float.rb +134 -0
- data/lib/rapitapir/types/hash.rb +161 -0
- data/lib/rapitapir/types/integer.rb +143 -0
- data/lib/rapitapir/types/object.rb +156 -0
- data/lib/rapitapir/types/optional.rb +65 -0
- data/lib/rapitapir/types/string.rb +185 -0
- data/lib/rapitapir/types/uuid.rb +32 -0
- data/lib/rapitapir/types.rb +155 -0
- data/lib/rapitapir/version.rb +5 -0
- data/lib/rapitapir.rb +173 -0
- data/rapitapir.gemspec +66 -0
- 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 .
|