rapitapir 0.1.1 → 2.0.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -7
  3. data/.rubocop_todo.yml +83 -0
  4. data/README.md +1319 -235
  5. data/RUBY_WEEKLY_LAUNCH_POST.md +219 -0
  6. data/docs/RAILS_INTEGRATION_IMPLEMENTATION.md +209 -0
  7. data/docs/SINATRA_EXTENSION.md +399 -348
  8. data/docs/STRICT_VALIDATION.md +229 -0
  9. data/docs/VALIDATION_IMPROVEMENTS.md +218 -0
  10. data/docs/ai-integration-plan.md +112 -0
  11. data/docs/auto-derivation.md +505 -92
  12. data/docs/endpoint-definition.md +536 -129
  13. data/docs/n8n-integration.md +212 -0
  14. data/docs/observability.md +810 -500
  15. data/docs/using-mcp.md +93 -0
  16. data/examples/ai/knowledge_base_rag.rb +83 -0
  17. data/examples/ai/user_management_mcp.rb +92 -0
  18. data/examples/ai/user_validation_llm.rb +187 -0
  19. data/examples/rails/RAILS_8_GUIDE.md +165 -0
  20. data/examples/rails/RAILS_LOADING_FIX.rb +35 -0
  21. data/examples/rails/README.md +497 -0
  22. data/examples/rails/comprehensive_test.rb +91 -0
  23. data/examples/rails/config/routes.rb +48 -0
  24. data/examples/rails/debug_controller.rb +63 -0
  25. data/examples/rails/detailed_test.rb +46 -0
  26. data/examples/rails/enhanced_users_controller.rb +278 -0
  27. data/examples/rails/final_server_test.rb +50 -0
  28. data/examples/rails/hello_world_app.rb +116 -0
  29. data/examples/rails/hello_world_controller.rb +186 -0
  30. data/examples/rails/hello_world_routes.rb +28 -0
  31. data/examples/rails/rails8_minimal_demo.rb +132 -0
  32. data/examples/rails/rails8_simple_demo.rb +140 -0
  33. data/examples/rails/rails8_working_demo.rb +255 -0
  34. data/examples/rails/real_world_blog_api.rb +510 -0
  35. data/examples/rails/server_test.rb +46 -0
  36. data/examples/rails/test_direct_processing.rb +41 -0
  37. data/examples/rails/test_hello_world.rb +80 -0
  38. data/examples/rails/test_rails_integration.rb +54 -0
  39. data/examples/rails/traditional_app/Gemfile +37 -0
  40. data/examples/rails/traditional_app/README.md +265 -0
  41. data/examples/rails/traditional_app/app/controllers/api/v1/posts_controller.rb +254 -0
  42. data/examples/rails/traditional_app/app/controllers/api/v1/users_controller.rb +220 -0
  43. data/examples/rails/traditional_app/app/controllers/application_controller.rb +86 -0
  44. data/examples/rails/traditional_app/app/controllers/application_controller_simplified.rb +87 -0
  45. data/examples/rails/traditional_app/app/controllers/documentation_controller.rb +149 -0
  46. data/examples/rails/traditional_app/app/controllers/health_controller.rb +42 -0
  47. data/examples/rails/traditional_app/config/routes.rb +25 -0
  48. data/examples/rails/traditional_app/config/routes_best_practice.rb +25 -0
  49. data/examples/rails/traditional_app/config/routes_simplified.rb +36 -0
  50. data/examples/rails/traditional_app_runnable.rb +406 -0
  51. data/examples/rails/users_controller.rb +4 -1
  52. data/examples/serverless/Gemfile +43 -0
  53. data/examples/serverless/QUICKSTART.md +331 -0
  54. data/examples/serverless/README.md +520 -0
  55. data/examples/serverless/aws_lambda_example.rb +307 -0
  56. data/examples/serverless/aws_sam_template.yaml +215 -0
  57. data/examples/serverless/azure_functions_example.rb +407 -0
  58. data/examples/serverless/deploy.rb +204 -0
  59. data/examples/serverless/gcp_cloud_functions_example.rb +367 -0
  60. data/examples/serverless/gcp_function.yaml +23 -0
  61. data/examples/serverless/host.json +24 -0
  62. data/examples/serverless/package.json +32 -0
  63. data/examples/serverless/spec/aws_lambda_spec.rb +196 -0
  64. data/examples/serverless/spec/spec_helper.rb +89 -0
  65. data/examples/serverless/vercel.json +31 -0
  66. data/examples/serverless/vercel_example.rb +404 -0
  67. data/examples/strict_validation_examples.rb +104 -0
  68. data/examples/validation_error_examples.rb +173 -0
  69. data/lib/rapitapir/ai/llm_instruction.rb +456 -0
  70. data/lib/rapitapir/ai/mcp.rb +134 -0
  71. data/lib/rapitapir/ai/rag.rb +287 -0
  72. data/lib/rapitapir/ai/rag_middleware.rb +147 -0
  73. data/lib/rapitapir/auth/oauth2.rb +43 -57
  74. data/lib/rapitapir/cli/command.rb +362 -2
  75. data/lib/rapitapir/cli/mcp_export.rb +18 -0
  76. data/lib/rapitapir/cli/validator.rb +2 -6
  77. data/lib/rapitapir/core/endpoint.rb +59 -6
  78. data/lib/rapitapir/core/enhanced_endpoint.rb +2 -6
  79. data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +53 -0
  80. data/lib/rapitapir/endpoint_registry.rb +47 -0
  81. data/lib/rapitapir/observability/health_check.rb +4 -4
  82. data/lib/rapitapir/observability/logging.rb +10 -10
  83. data/lib/rapitapir/schema.rb +2 -2
  84. data/lib/rapitapir/server/rack_adapter.rb +1 -3
  85. data/lib/rapitapir/server/rails/configuration.rb +77 -0
  86. data/lib/rapitapir/server/rails/controller_base.rb +185 -0
  87. data/lib/rapitapir/server/rails/documentation_helpers.rb +76 -0
  88. data/lib/rapitapir/server/rails/resource_builder.rb +181 -0
  89. data/lib/rapitapir/server/rails/routes.rb +114 -0
  90. data/lib/rapitapir/server/rails_adapter.rb +10 -3
  91. data/lib/rapitapir/server/rails_adapter_class.rb +1 -3
  92. data/lib/rapitapir/server/rails_controller.rb +1 -3
  93. data/lib/rapitapir/server/rails_integration.rb +67 -0
  94. data/lib/rapitapir/server/rails_response_handler.rb +16 -3
  95. data/lib/rapitapir/server/sinatra_adapter.rb +29 -5
  96. data/lib/rapitapir/server/sinatra_integration.rb +4 -4
  97. data/lib/rapitapir/sinatra/extension.rb +2 -2
  98. data/lib/rapitapir/sinatra/oauth2_helpers.rb +34 -40
  99. data/lib/rapitapir/types/array.rb +4 -0
  100. data/lib/rapitapir/types/auto_derivation.rb +4 -18
  101. data/lib/rapitapir/types/datetime.rb +1 -3
  102. data/lib/rapitapir/types/float.rb +2 -6
  103. data/lib/rapitapir/types/hash.rb +40 -2
  104. data/lib/rapitapir/types/integer.rb +4 -12
  105. data/lib/rapitapir/types/object.rb +6 -2
  106. data/lib/rapitapir/types.rb +6 -2
  107. data/lib/rapitapir/version.rb +1 -1
  108. data/lib/rapitapir.rb +5 -3
  109. data/rapitapir.gemspec +7 -5
  110. metadata +116 -16
@@ -11,20 +11,20 @@ module RapiTapir
11
11
  # Based on the Auth0 Sinatra integration patterns
12
12
  class Auth0Scheme < Schemes::Base
13
13
  attr_reader :domain, :audience, :algorithm, :jwks_cache_ttl
14
-
14
+
15
15
  def jwks_url
16
16
  "https://#{@domain}/.well-known/jwks.json"
17
17
  end
18
18
 
19
19
  def initialize(name = :oauth2_auth0, config = {})
20
- super(name, config)
21
-
20
+ super
21
+
22
22
  @domain = config[:domain] || raise(ArgumentError, 'Auth0 domain is required')
23
23
  @audience = config[:audience] || raise(ArgumentError, 'Auth0 audience is required')
24
24
  @algorithm = config[:algorithm] || 'RS256'
25
25
  @jwks_cache_ttl = config[:jwks_cache_ttl] || 300 # 5 minutes
26
26
  @realm = config[:realm] || 'API'
27
-
27
+
28
28
  # Cache for JWKS to avoid frequent requests
29
29
  @jwks_cache = nil
30
30
  @jwks_cache_time = nil
@@ -35,13 +35,9 @@ module RapiTapir
35
35
  return nil unless auth_header
36
36
 
37
37
  token = extract_bearer_token(auth_header)
38
- if token.nil?
39
- raise AuthenticationError, "Invalid authorization header format"
40
- end
41
-
42
- if token.strip.empty?
43
- raise AuthenticationError, "Token cannot be empty"
44
- end
38
+ raise AuthenticationError, 'Invalid authorization header format' if token.nil?
39
+
40
+ raise AuthenticationError, 'Token cannot be empty' if token.strip.empty?
45
41
 
46
42
  begin
47
43
  decoded_token = validate_auth0_token(token)
@@ -49,7 +45,7 @@ module RapiTapir
49
45
 
50
46
  # Extract user info and scopes from the token
51
47
  payload = decoded_token.first
52
-
48
+
53
49
  create_context(
54
50
  user: extract_user_from_token(payload),
55
51
  scopes: extract_scopes_from_token(payload),
@@ -88,9 +84,9 @@ module RapiTapir
88
84
 
89
85
  def validate_auth0_token(token)
90
86
  require 'jwt' # Ensure JWT gem is available
91
-
87
+
92
88
  jwks = fetch_jwks
93
-
89
+
94
90
  JWT.decode(
95
91
  token,
96
92
  nil,
@@ -108,37 +104,33 @@ module RapiTapir
108
104
 
109
105
  def fetch_jwks
110
106
  # Return cached JWKS if still valid
111
- if @jwks_cache && @jwks_cache_time && (Time.now - @jwks_cache_time) < @jwks_cache_ttl
112
- return @jwks_cache
113
- end
107
+ return @jwks_cache if @jwks_cache && @jwks_cache_time && (Time.now - @jwks_cache_time) < @jwks_cache_ttl
114
108
 
115
109
  # Fetch fresh JWKS from Auth0
116
110
  jwks_response = get_jwks_from_auth0
117
-
118
- unless jwks_response.is_a?(Net::HTTPSuccess)
119
- raise AuthenticationError, 'Unable to fetch JWKS from Auth0'
120
- end
111
+
112
+ raise AuthenticationError, 'Unable to fetch JWKS from Auth0' unless jwks_response.is_a?(Net::HTTPSuccess)
121
113
 
122
114
  jwks_data = JSON.parse(jwks_response.body)
123
-
115
+
124
116
  # Cache the JWKS
125
117
  @jwks_cache = { keys: jwks_data['keys'] }
126
118
  @jwks_cache_time = Time.now
127
-
119
+
128
120
  @jwks_cache
129
121
  end
130
122
 
131
123
  def get_jwks_from_auth0
132
124
  jwks_uri = URI("#{base_domain_url}/.well-known/jwks.json")
133
-
125
+
134
126
  http = Net::HTTP.new(jwks_uri.host, jwks_uri.port)
135
127
  http.use_ssl = true
136
128
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
137
129
  http.read_timeout = 10
138
-
130
+
139
131
  request = Net::HTTP::Get.new(jwks_uri.request_uri)
140
132
  request['User-Agent'] = 'RapiTapir OAuth2 Client'
141
-
133
+
142
134
  http.request(request)
143
135
  end
144
136
 
@@ -166,17 +158,17 @@ module RapiTapir
166
158
  scope_string = payload['scope']
167
159
  return [] unless scope_string
168
160
 
169
- scope_string.split(' ')
161
+ scope_string.split
170
162
  end
171
163
  end
172
164
 
173
165
  # Generic OAuth2 scheme with token introspection support
174
166
  class GenericScheme < Schemes::Base
175
167
  attr_reader :introspection_endpoint, :client_id, :client_secret, :token_cache_ttl
176
-
168
+
177
169
  def initialize(name = :oauth2, config = {})
178
- super(name, config)
179
-
170
+ super
171
+
180
172
  @introspection_endpoint = config[:introspection_endpoint]
181
173
  @client_id = config[:client_id]
182
174
  @client_secret = config[:client_secret]
@@ -184,7 +176,7 @@ module RapiTapir
184
176
  @realm = config[:realm] || 'API'
185
177
  @cache_tokens = config.fetch(:cache_tokens, true)
186
178
  @token_cache_ttl = config[:token_cache_ttl] || 300 # 5 minutes
187
-
179
+
188
180
  # Token cache to avoid repeated introspection calls
189
181
  @token_cache = {} if @cache_tokens
190
182
  end
@@ -198,11 +190,9 @@ module RapiTapir
198
190
 
199
191
  begin
200
192
  token_info = validate_oauth2_token(token)
201
-
193
+
202
194
  # Check if token is active
203
- unless token_info && token_info[:active]
204
- raise AuthenticationError, "Token is not active"
205
- end
195
+ raise AuthenticationError, 'Token is not active' unless token_info && token_info[:active]
206
196
 
207
197
  create_context(
208
198
  user: token_info[:user],
@@ -242,19 +232,17 @@ module RapiTapir
242
232
  # Check cache first
243
233
  if @cache_tokens && @token_cache
244
234
  cached = @token_cache[token]
245
- if cached && (Time.now - cached[:cached_at]) < @token_cache_ttl
246
- return cached[:data]
247
- end
235
+ return cached[:data] if cached && (Time.now - cached[:cached_at]) < @token_cache_ttl
248
236
  end
249
237
 
250
238
  # Validate token
251
239
  token_info = if @introspection_endpoint
252
- introspect_token_via_endpoint(token)
253
- elsif @token_validator
254
- @token_validator.call(token)
255
- else
256
- default_token_validation(token)
257
- end
240
+ introspect_token_via_endpoint(token)
241
+ elsif @token_validator
242
+ @token_validator.call(token)
243
+ else
244
+ default_token_validation(token)
245
+ end
258
246
 
259
247
  # Cache the result
260
248
  if @cache_tokens && @token_cache && token_info
@@ -269,40 +257,38 @@ module RapiTapir
269
257
 
270
258
  def introspect_token_via_endpoint(token)
271
259
  uri = URI(@introspection_endpoint)
272
-
260
+
273
261
  http = Net::HTTP.new(uri.host, uri.port)
274
262
  http.use_ssl = (uri.scheme == 'https')
275
-
263
+
276
264
  request = Net::HTTP::Post.new(uri.request_uri)
277
265
  request['Content-Type'] = 'application/x-www-form-urlencoded'
278
266
  request['Accept'] = 'application/json'
279
-
267
+
280
268
  # Add client authentication
281
269
  if @client_id && @client_secret
282
270
  credentials = Base64.strict_encode64("#{@client_id}:#{@client_secret}")
283
271
  request['Authorization'] = "Basic #{credentials}"
284
272
  end
285
-
273
+
286
274
  # Set form data
287
275
  request.set_form_data(
288
276
  'token' => token,
289
277
  'token_type_hint' => 'access_token'
290
278
  )
291
-
279
+
292
280
  response = http.request(request)
293
-
294
- unless response.is_a?(Net::HTTPSuccess)
295
- raise AuthenticationError, "Token introspection failed: #{response.code} #{response.message}"
296
- end
297
-
281
+
282
+ raise AuthenticationError, "Token introspection failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
283
+
298
284
  begin
299
285
  introspection_data = JSON.parse(response.body)
300
286
  rescue JSON::ParserError => e
301
287
  raise AuthenticationError, "Invalid introspection response: #{e.message}"
302
288
  end
303
-
289
+
304
290
  return { active: false } unless introspection_data['active']
305
-
291
+
306
292
  {
307
293
  active: true,
308
294
  user: extract_user_from_introspection(introspection_data),
@@ -324,7 +310,7 @@ module RapiTapir
324
310
 
325
311
  def extract_scopes_from_introspection(data)
326
312
  if data['scope'].is_a?(String)
327
- data['scope'].split(' ')
313
+ data['scope'].split
328
314
  elsif data['scope'].is_a?(Array)
329
315
  data['scope']
330
316
  else
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'optparse'
4
4
  require 'fileutils'
5
+ require 'json'
5
6
 
6
7
  module RapiTapir
7
8
  module CLI
@@ -38,10 +39,14 @@ module RapiTapir
38
39
  case command
39
40
  when 'generate'
40
41
  run_generate(@args)
42
+ when 'export'
43
+ run_export(@args)
41
44
  when 'serve'
42
45
  run_serve(@args)
43
46
  when 'validate'
44
47
  run_validate(@args)
48
+ when 'ai'
49
+ run_ai(@args)
45
50
  when 'version'
46
51
  puts "RapiTapir version #{RapiTapir::VERSION}"
47
52
  when 'help', nil
@@ -153,7 +158,7 @@ module RapiTapir
153
158
  type = args.shift
154
159
  unless type
155
160
  puts 'Error: Generate command requires a type'
156
- puts 'Available types: openapi, client, docs'
161
+ puts 'Available types: openapi, client, docs, mcp'
157
162
  exit 1
158
163
  end
159
164
 
@@ -166,9 +171,11 @@ module RapiTapir
166
171
  when 'docs'
167
172
  docs_type = args.shift || 'html'
168
173
  generate_docs(docs_type)
174
+ when 'mcp'
175
+ generate_mcp
169
176
  else
170
177
  puts "Error: Unknown generation type: #{type}"
171
- puts 'Available types: openapi, client, docs'
178
+ puts 'Available types: openapi, client, docs, mcp'
172
179
  exit 1
173
180
  end
174
181
  end
@@ -206,6 +213,24 @@ module RapiTapir
206
213
  end
207
214
  end
208
215
 
216
+ def run_export(args)
217
+ type = args.shift
218
+ unless type
219
+ puts 'Error: Export command requires a type'
220
+ puts 'Available types: mcp'
221
+ exit 1
222
+ end
223
+
224
+ case type
225
+ when 'mcp'
226
+ export_mcp
227
+ else
228
+ puts "Error: Unknown export type: #{type}"
229
+ puts 'Available types: mcp'
230
+ exit 1
231
+ end
232
+ end
233
+
209
234
  def generate_openapi
210
235
  validate_openapi_options
211
236
 
@@ -412,6 +437,33 @@ module RapiTapir
412
437
  end
413
438
  end
414
439
 
440
+ def generate_mcp
441
+ validate_mcp_options
442
+
443
+ endpoints = load_endpoints(@options[:input])
444
+ require_relative '../ai/mcp'
445
+ exporter = RapiTapir::AI::MCP::Exporter.new(endpoints)
446
+ content = JSON.pretty_generate(exporter.as_mcp_context)
447
+ save_mcp_output(content)
448
+ end
449
+
450
+ def export_mcp
451
+ generate_mcp
452
+ end
453
+
454
+ def validate_mcp_options
455
+ return if @options[:input]
456
+
457
+ puts 'Error: --endpoints is required'
458
+ exit 1
459
+ end
460
+
461
+ def save_mcp_output(content)
462
+ output_file = @options[:output] || 'mcp-context.json'
463
+ File.write(output_file, content)
464
+ puts "MCP context exported to #{output_file}"
465
+ end
466
+
415
467
  def load_endpoints(file_path)
416
468
  raise "Input file not found: #{file_path}" unless File.exist?(file_path)
417
469
 
@@ -530,6 +582,314 @@ module RapiTapir
530
582
 
531
583
  endpoints
532
584
  end
585
+
586
+ def run_ai(args)
587
+ subcommand = args.shift
588
+
589
+ case subcommand
590
+ when 'rag'
591
+ run_ai_rag(args)
592
+ when 'query'
593
+ run_ai_query(args)
594
+ when 'llm'
595
+ run_ai_llm(args)
596
+ when 'help', nil
597
+ puts ai_help
598
+ else
599
+ puts "Unknown AI subcommand: #{subcommand}"
600
+ puts ai_help
601
+ exit 1
602
+ end
603
+ end
604
+
605
+ def run_ai_rag(args)
606
+ command = args.shift
607
+
608
+ case command
609
+ when 'test'
610
+ run_rag_test(args)
611
+ when 'setup'
612
+ run_rag_setup(args)
613
+ else
614
+ puts 'RAG subcommands: test, setup'
615
+ exit 1
616
+ end
617
+ end
618
+
619
+ def run_rag_test(args)
620
+ unless @options[:input]
621
+ puts 'Error: --endpoints is required'
622
+ exit 1
623
+ end
624
+
625
+ endpoints = load_endpoints(@options[:input])
626
+
627
+ # Find RAG-enabled endpoints
628
+ rag_endpoints = endpoints.select(&:rag_inference?)
629
+
630
+ if rag_endpoints.empty?
631
+ puts "No RAG-enabled endpoints found in #{@options[:input]}"
632
+ puts 'Add .rag_inference to your endpoints to enable RAG capabilities'
633
+ exit 1
634
+ end
635
+
636
+ require_relative '../ai/rag'
637
+
638
+ puts 'Testing RAG functionality...'
639
+ puts "Found #{rag_endpoints.size} RAG-enabled endpoint(s):"
640
+
641
+ rag_endpoints.each do |endpoint|
642
+ puts " #{endpoint.method} #{endpoint.path}"
643
+ config = endpoint.rag_config
644
+
645
+ # Create pipeline and test
646
+ pipeline = RapiTapir::AI::RAG::Pipeline.new(
647
+ llm: config[:llm] || :openai,
648
+ retrieval: config[:retrieval] || :memory,
649
+ config: config[:config] || {}
650
+ )
651
+
652
+ test_query = args.first || 'What can this API do?'
653
+ result = pipeline.process(test_query)
654
+
655
+ puts " Query: #{test_query}"
656
+ puts " Answer: #{result[:answer][0..100]}#{'...' if result[:answer].length > 100}"
657
+ puts " Sources: #{result[:sources].size} document(s)"
658
+ puts
659
+ end
660
+ end
661
+
662
+ def run_rag_setup(_args)
663
+ puts 'Setting up RAG configuration...'
664
+
665
+ config_template = {
666
+ rag: {
667
+ llm: {
668
+ provider: 'openai',
669
+ api_key: 'your-openai-api-key'
670
+ },
671
+ retrieval: {
672
+ backend: 'memory',
673
+ documents_path: './docs/**/*.md'
674
+ }
675
+ }
676
+ }
677
+
678
+ config_file = 'rapitapir_ai.json'
679
+
680
+ if File.exist?(config_file)
681
+ puts "Configuration file #{config_file} already exists"
682
+ exit 1
683
+ end
684
+
685
+ File.write(config_file, JSON.pretty_generate(config_template))
686
+ puts "Created #{config_file}"
687
+ puts 'Please update the configuration with your API keys and document paths'
688
+ end
689
+
690
+ def run_ai_query(args)
691
+ query = args.join(' ')
692
+
693
+ if query.empty?
694
+ puts 'Usage: rapitapir ai query <your question>'
695
+ exit 1
696
+ end
697
+
698
+ unless @options[:input]
699
+ puts 'Error: --endpoints is required'
700
+ exit 1
701
+ end
702
+
703
+ endpoints = load_endpoints(@options[:input])
704
+
705
+ # Export MCP context for the query
706
+ require_relative '../ai/mcp'
707
+ exporter = RapiTapir::AI::MCP::Exporter.new(endpoints)
708
+ context = exporter.as_mcp_context
709
+
710
+ puts 'API Context for AI Agents:'
711
+ puts '========================='
712
+ puts
713
+ puts "Query: #{query}"
714
+ puts
715
+ puts 'Available Endpoints:'
716
+ if context && context[:endpoints]
717
+ context[:endpoints].each do |ep|
718
+ puts " #{ep[:method]} #{ep[:path]} - #{ep[:summary]}"
719
+ end
720
+ else
721
+ puts ' No endpoints found'
722
+ end
723
+ puts
724
+ puts 'This context can be used by AI agents to understand your API structure.'
725
+ end
726
+
727
+ def run_ai_llm(args)
728
+ command = args.shift
729
+
730
+ case command
731
+ when 'generate'
732
+ run_llm_generate(args)
733
+ when 'export'
734
+ run_llm_export(args)
735
+ when 'test'
736
+ run_llm_test(args)
737
+ else
738
+ puts 'LLM subcommands: generate, export, test'
739
+ exit 1
740
+ end
741
+ end
742
+
743
+ def run_llm_generate(_args)
744
+ unless @options[:input]
745
+ puts 'Error: --endpoints is required'
746
+ exit 1
747
+ end
748
+
749
+ endpoints = load_endpoints(@options[:input])
750
+
751
+ # Find LLM instruction-enabled endpoints
752
+ llm_endpoints = endpoints.select(&:llm_instruction?)
753
+
754
+ if llm_endpoints.empty?
755
+ puts "No LLM instruction-enabled endpoints found in #{@options[:input]}"
756
+ puts 'Add .llm_instruction(purpose: :validation) to your endpoints to enable LLM instruction generation'
757
+ exit 1
758
+ end
759
+
760
+ require_relative '../ai/llm_instruction'
761
+
762
+ puts 'Generating LLM instructions...'
763
+ puts "Found #{llm_endpoints.size} LLM instruction-enabled endpoint(s):"
764
+
765
+ generator = RapiTapir::AI::LLMInstruction::Generator.new(llm_endpoints)
766
+ instructions = generator.generate_all_instructions
767
+
768
+ instructions[:instructions].each do |instruction|
769
+ puts "\n#{'=' * 60}"
770
+ puts "#{instruction[:method]} #{instruction[:path]} (#{instruction[:purpose]})"
771
+ puts '=' * 60
772
+ puts instruction[:instruction]
773
+ end
774
+
775
+ puts "\n#{'=' * 60}"
776
+ puts "Generated #{instructions[:instructions].size} LLM instructions"
777
+ end
778
+
779
+ def run_llm_export(args)
780
+ format = args.shift || 'json'
781
+ output_file = @options[:output]
782
+
783
+ unless @options[:input]
784
+ puts 'Error: --endpoints is required'
785
+ exit 1
786
+ end
787
+
788
+ endpoints = load_endpoints(@options[:input])
789
+ llm_endpoints = endpoints.select(&:llm_instruction?)
790
+
791
+ if llm_endpoints.empty?
792
+ puts 'No LLM instruction-enabled endpoints found'
793
+ exit 1
794
+ end
795
+
796
+ require_relative '../ai/llm_instruction'
797
+
798
+ generator = RapiTapir::AI::LLMInstruction::Generator.new(llm_endpoints)
799
+ instructions = generator.generate_all_instructions
800
+ exporter = RapiTapir::AI::LLMInstruction::Exporter.new(instructions)
801
+
802
+ case format.downcase
803
+ when 'json'
804
+ output = exporter.to_json
805
+ extension = '.json'
806
+ when 'yaml', 'yml'
807
+ output = exporter.to_yaml
808
+ extension = '.yml'
809
+ when 'markdown', 'md'
810
+ output = exporter.to_markdown
811
+ extension = '.md'
812
+ when 'prompts'
813
+ if output_file
814
+ puts exporter.to_prompt_files(output_file)
815
+ return
816
+ else
817
+ puts 'Error: --output directory is required for prompts format'
818
+ exit 1
819
+ end
820
+ else
821
+ puts "Error: Unsupported format '#{format}'. Supported: json, yaml, markdown, prompts"
822
+ exit 1
823
+ end
824
+
825
+ if output_file
826
+ output_file += extension unless output_file.end_with?(extension)
827
+ File.write(output_file, output)
828
+ puts "LLM instructions exported to #{output_file}"
829
+ else
830
+ puts output
831
+ end
832
+ end
833
+
834
+ def run_llm_test(args)
835
+ purpose = args.shift
836
+
837
+ unless purpose
838
+ puts 'Usage: rapitapir ai llm test <purpose>'
839
+ puts 'Available purposes: validation, transformation, analysis, documentation, testing, completion'
840
+ exit 1
841
+ end
842
+
843
+ unless @options[:input]
844
+ puts 'Error: --endpoints is required'
845
+ exit 1
846
+ end
847
+
848
+ endpoints = load_endpoints(@options[:input])
849
+ llm_endpoints = endpoints.select(&:llm_instruction?)
850
+
851
+ if llm_endpoints.empty?
852
+ puts 'No LLM instruction-enabled endpoints found'
853
+ exit 1
854
+ end
855
+
856
+ require_relative '../ai/llm_instruction'
857
+
858
+ puts "Testing LLM instruction generation for purpose: #{purpose}"
859
+ puts
860
+
861
+ generator = RapiTapir::AI::LLMInstruction::Generator.new(llm_endpoints)
862
+
863
+ llm_endpoints.each do |endpoint|
864
+ config = endpoint.llm_instruction_config.dup
865
+ config[:purpose] = purpose.to_sym
866
+
867
+ begin
868
+ instruction = generator.generate_instruction(endpoint, config)
869
+ puts "✅ #{endpoint.method&.upcase} #{endpoint.path}"
870
+ puts " Purpose: #{instruction[:purpose]}"
871
+ puts " Length: #{instruction[:instruction].length} characters"
872
+ puts
873
+ rescue StandardError => e
874
+ puts "❌ #{endpoint.method&.upcase} #{endpoint.path}"
875
+ puts " Error: #{e.message}"
876
+ puts
877
+ end
878
+ end
879
+ end
880
+
881
+ def ai_help
882
+ <<~HELP
883
+ AI Commands:
884
+ rapitapir ai rag test [query] - Test RAG functionality on your API
885
+ rapitapir ai rag setup - Create RAG configuration template
886
+ rapitapir ai query <question> - Get AI-ready context for a query
887
+ rapitapir ai llm generate - Generate LLM instructions from endpoints
888
+ rapitapir ai llm export [format] - Export LLM instructions (json, yaml, markdown, prompts)
889
+ rapitapir ai llm test <purpose> - Test LLM instruction generation for a purpose
890
+ rapitapir ai help - Show this help
891
+ HELP
892
+ end
533
893
  end
534
894
  end
535
895
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/endpoint'
4
+ require_relative '../ai/mcp'
5
+ require 'json'
6
+
7
+ module RapiTapir
8
+ module CLI
9
+ class MCPExport
10
+ # Exports all endpoints marked for MCP as a JSON file
11
+ def self.run(endpoints, output_path = 'mcp-context.json')
12
+ exporter = RapiTapir::AI::MCP::Exporter.new(endpoints)
13
+ File.write(output_path, JSON.pretty_generate(exporter.as_mcp_context))
14
+ puts "MCP context exported to #{output_path}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -112,9 +112,7 @@ module RapiTapir
112
112
  # Check path parameter format
113
113
  path.scan(/:(\w+)/).each do |param_match|
114
114
  param_name = param_match[0]
115
- unless param_name.match?(/^[a-zA-Z][a-zA-Z0-9_]*$/)
116
- @errors << "#{context}: Invalid path parameter name '#{param_name}'"
117
- end
115
+ @errors << "#{context}: Invalid path parameter name '#{param_name}'" unless param_name.match?(/^[a-zA-Z][a-zA-Z0-9_]*$/)
118
116
  end
119
117
  end
120
118
 
@@ -219,9 +217,7 @@ module RapiTapir
219
217
 
220
218
  def validate_hash_schema(schema, context)
221
219
  schema.each do |key, value|
222
- unless key.is_a?(Symbol) || key.is_a?(String)
223
- @errors << "#{context}: Hash key '#{key}' must be a symbol or string"
224
- end
220
+ @errors << "#{context}: Hash key '#{key}' must be a symbol or string" unless key.is_a?(Symbol) || key.is_a?(String)
225
221
 
226
222
  validate_type(value, "#{context}.#{key}")
227
223
  end