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,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RapiTapir
|
4
|
+
module Sinatra
|
5
|
+
# Resource builder for creating RESTful API endpoints
|
6
|
+
# Follows Open/Closed Principle - extensible for new resource types
|
7
|
+
class ResourceBuilder
|
8
|
+
def initialize(app, base_path, schema, **options)
|
9
|
+
@app = app
|
10
|
+
@base_path = base_path.chomp('/')
|
11
|
+
@schema = schema
|
12
|
+
@options = options
|
13
|
+
@endpoints = []
|
14
|
+
end
|
15
|
+
|
16
|
+
# Enable standard CRUD operations
|
17
|
+
def crud(except: [], only: nil, **handlers, &block)
|
18
|
+
operations = only || %i[index show create update destroy]
|
19
|
+
operations -= except if except.any?
|
20
|
+
|
21
|
+
# If a block is given, evaluate it in the context of this ResourceBuilder
|
22
|
+
# This allows method calls like: index { BookStore.all }
|
23
|
+
if block_given?
|
24
|
+
instance_eval(&block)
|
25
|
+
else
|
26
|
+
# Legacy style with handlers hash
|
27
|
+
operations.each do |operation|
|
28
|
+
send(operation, &handlers[operation]) if respond_to?(operation, true)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# List all resources (GET /resources)
|
34
|
+
def index(**options, &handler)
|
35
|
+
handler ||= proc { [] }
|
36
|
+
|
37
|
+
endpoint = build_index_endpoint(options)
|
38
|
+
@app.endpoint(endpoint, &handler)
|
39
|
+
end
|
40
|
+
|
41
|
+
def build_index_endpoint(options)
|
42
|
+
RapiTapir.get(@base_path)
|
43
|
+
.summary(options[:summary] || "List all #{resource_name}")
|
44
|
+
.description(options[:description] || "Retrieve a list of #{resource_name}")
|
45
|
+
.then { |ep| add_pagination_params(ep) }
|
46
|
+
.ok(RapiTapir::Types.array(@schema))
|
47
|
+
.build
|
48
|
+
end
|
49
|
+
|
50
|
+
def add_pagination_params(endpoint)
|
51
|
+
endpoint.query(:limit, RapiTapir::Types.optional(RapiTapir::Types.integer),
|
52
|
+
description: 'Maximum number of results')
|
53
|
+
.query(:offset, RapiTapir::Types.optional(RapiTapir::Types.integer),
|
54
|
+
description: 'Number of results to skip')
|
55
|
+
end
|
56
|
+
|
57
|
+
# Get specific resource (GET /resources/:id)
|
58
|
+
def show(**options, &handler)
|
59
|
+
handler ||= proc { {} }
|
60
|
+
|
61
|
+
endpoint = RapiTapir.get("#{@base_path}/:id")
|
62
|
+
.summary(options[:summary] || "Get #{resource_name}")
|
63
|
+
.description(options[:description] || "Retrieve a specific #{resource_name} by ID")
|
64
|
+
.path_param(:id, RapiTapir::Types.integer, description: "#{resource_name.capitalize} ID")
|
65
|
+
.ok(@schema)
|
66
|
+
.build
|
67
|
+
|
68
|
+
@app.endpoint(endpoint, &handler)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Create new resource (POST /resources)
|
72
|
+
def create(**options, &handler)
|
73
|
+
handler ||= proc { {} }
|
74
|
+
|
75
|
+
endpoint = RapiTapir.post(@base_path)
|
76
|
+
.summary(options[:summary] || "Create #{resource_name}")
|
77
|
+
.description(options[:description] || "Create a new #{resource_name}")
|
78
|
+
.json_body(@schema)
|
79
|
+
.created(@schema)
|
80
|
+
.build
|
81
|
+
|
82
|
+
@app.endpoint(endpoint, &handler)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Update resource (PUT /resources/:id)
|
86
|
+
def update(**options, &handler)
|
87
|
+
handler ||= proc { {} }
|
88
|
+
|
89
|
+
endpoint = RapiTapir.put("#{@base_path}/:id")
|
90
|
+
.summary(options[:summary] || "Update #{resource_name}")
|
91
|
+
.description(options[:description] || "Update an existing #{resource_name}")
|
92
|
+
.path_param(:id, RapiTapir::Types.integer, description: "#{resource_name.capitalize} ID")
|
93
|
+
.json_body(@schema)
|
94
|
+
.ok(@schema)
|
95
|
+
.build
|
96
|
+
|
97
|
+
@app.endpoint(endpoint, &handler)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Delete resource (DELETE /resources/:id)
|
101
|
+
def destroy(**options, &handler)
|
102
|
+
handler ||= proc { status 204 }
|
103
|
+
|
104
|
+
endpoint = RapiTapir.delete("#{@base_path}/:id")
|
105
|
+
.summary(options[:summary] || "Delete #{resource_name}")
|
106
|
+
.description(options[:description] || "Delete a #{resource_name}")
|
107
|
+
.path_param(:id, RapiTapir::Types.integer, description: "#{resource_name.capitalize} ID")
|
108
|
+
.no_content
|
109
|
+
.build
|
110
|
+
|
111
|
+
@app.endpoint(endpoint, &handler)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Custom endpoint within the resource
|
115
|
+
def custom(method, path, summary: nil, configure: nil, **_options, &handler)
|
116
|
+
full_path = path.start_with?('/') ? path : "#{@base_path}/#{path}"
|
117
|
+
|
118
|
+
endpoint = RapiTapir.send(method, full_path)
|
119
|
+
endpoint = endpoint.summary(summary) if summary
|
120
|
+
endpoint = configure.call(endpoint) if configure
|
121
|
+
|
122
|
+
@app.endpoint(endpoint.build, &handler)
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def resource_name
|
128
|
+
@resource_name ||= @base_path.split('/').last.singularize
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# String extensions for pluralization/singularization
|
135
|
+
class String
|
136
|
+
def pluralize
|
137
|
+
case self
|
138
|
+
when /s$/ then self
|
139
|
+
when /y$/ then sub(/y$/, 'ies')
|
140
|
+
when /(ch|sh|x|z)$/ then "#{self}es"
|
141
|
+
else "#{self}s"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def singularize
|
146
|
+
case self
|
147
|
+
when /ies$/ then sub(/ies$/, 'y')
|
148
|
+
when /s$/ then chomp('s')
|
149
|
+
else self
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RapiTapir
|
4
|
+
module Sinatra
|
5
|
+
# Swagger UI HTML generator
|
6
|
+
# Follows Single Responsibility Principle - only generates UI HTML
|
7
|
+
class SwaggerUIGenerator
|
8
|
+
def initialize(openapi_path, api_info)
|
9
|
+
@openapi_path = openapi_path
|
10
|
+
@api_info = api_info
|
11
|
+
end
|
12
|
+
|
13
|
+
def generate
|
14
|
+
<<~HTML
|
15
|
+
<!DOCTYPE html>
|
16
|
+
<html lang="en">
|
17
|
+
<head>
|
18
|
+
<meta charset="UTF-8">
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
20
|
+
<title>#{@api_info[:title]} - API Documentation</title>
|
21
|
+
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
|
22
|
+
<style>
|
23
|
+
#{custom_styles}
|
24
|
+
</style>
|
25
|
+
</head>
|
26
|
+
<body>
|
27
|
+
#{header_banner}
|
28
|
+
<div id="swagger-ui"></div>
|
29
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
|
30
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-standalone-preset.js"></script>
|
31
|
+
<script>
|
32
|
+
#{swagger_ui_config}
|
33
|
+
</script>
|
34
|
+
</body>
|
35
|
+
</html>
|
36
|
+
HTML
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def custom_styles
|
42
|
+
<<~CSS
|
43
|
+
html {
|
44
|
+
box-sizing: border-box;
|
45
|
+
overflow: -moz-scrollbars-vertical;
|
46
|
+
overflow-y: scroll;
|
47
|
+
}
|
48
|
+
*, *:before, *:after {
|
49
|
+
box-sizing: inherit;
|
50
|
+
}
|
51
|
+
body {
|
52
|
+
margin: 0;
|
53
|
+
background: #fafafa;
|
54
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
55
|
+
}
|
56
|
+
.swagger-ui .topbar {
|
57
|
+
display: none;
|
58
|
+
}
|
59
|
+
.info-banner {
|
60
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
61
|
+
color: white;
|
62
|
+
padding: 30px 20px;
|
63
|
+
text-align: center;
|
64
|
+
margin-bottom: 20px;
|
65
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
66
|
+
}
|
67
|
+
.info-banner h1 {
|
68
|
+
margin: 0 0 10px 0;
|
69
|
+
font-size: 28px;
|
70
|
+
font-weight: 600;
|
71
|
+
}
|
72
|
+
.info-banner p {
|
73
|
+
margin: 0;
|
74
|
+
opacity: 0.9;
|
75
|
+
font-size: 16px;
|
76
|
+
}
|
77
|
+
.info-banner .badge {
|
78
|
+
display: inline-block;
|
79
|
+
background: rgba(255, 255, 255, 0.2);
|
80
|
+
padding: 4px 12px;
|
81
|
+
border-radius: 20px;
|
82
|
+
font-size: 14px;
|
83
|
+
margin: 10px 5px 0 0;
|
84
|
+
}
|
85
|
+
.swagger-ui .scheme-container {
|
86
|
+
padding: 30px 0;
|
87
|
+
background: white;
|
88
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
89
|
+
}
|
90
|
+
CSS
|
91
|
+
end
|
92
|
+
|
93
|
+
def header_banner
|
94
|
+
<<~HTML
|
95
|
+
<div class="info-banner">
|
96
|
+
<h1>#{@api_info[:title]}</h1>
|
97
|
+
<p>#{@api_info[:description]}</p>
|
98
|
+
<div>
|
99
|
+
<span class="badge">v#{@api_info[:version]}</span>
|
100
|
+
<span class="badge">🚀 RapiTapir</span>
|
101
|
+
<span class="badge">⚡ Auto-generated</span>
|
102
|
+
</div>
|
103
|
+
</div>
|
104
|
+
HTML
|
105
|
+
end
|
106
|
+
|
107
|
+
def swagger_ui_config
|
108
|
+
<<~JS
|
109
|
+
window.onload = function() {
|
110
|
+
const ui = SwaggerUIBundle({
|
111
|
+
url: '#{@openapi_path}',
|
112
|
+
dom_id: '#swagger-ui',
|
113
|
+
deepLinking: true,
|
114
|
+
presets: [
|
115
|
+
SwaggerUIBundle.presets.apis,
|
116
|
+
SwaggerUIStandalonePreset
|
117
|
+
],
|
118
|
+
plugins: [
|
119
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
120
|
+
],
|
121
|
+
layout: "StandaloneLayout",
|
122
|
+
tryItOutEnabled: true,
|
123
|
+
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
|
124
|
+
validatorUrl: null,
|
125
|
+
docExpansion: 'list',
|
126
|
+
filter: true,
|
127
|
+
showExtensions: true,
|
128
|
+
showCommonExtensions: true,
|
129
|
+
defaultModelsExpandDepth: 2,
|
130
|
+
defaultModelExpandDepth: 2,
|
131
|
+
displayRequestDuration: true,
|
132
|
+
requestInterceptor: function(request) {
|
133
|
+
// Add custom request headers or modify requests here
|
134
|
+
return request;
|
135
|
+
},
|
136
|
+
responseInterceptor: function(response) {
|
137
|
+
// Handle responses here
|
138
|
+
return response;
|
139
|
+
},
|
140
|
+
onComplete: function() {
|
141
|
+
console.log('🚀 RapiTapir Swagger UI loaded successfully');
|
142
|
+
console.log('📋 OpenAPI spec auto-generated from RapiTapir endpoints');
|
143
|
+
console.log('🔧 Powered by RapiTapir Sinatra Extension');
|
144
|
+
},
|
145
|
+
onFailure: function(error) {
|
146
|
+
console.error('Failed to load Swagger UI:', error);
|
147
|
+
}
|
148
|
+
});
|
149
|
+
|
150
|
+
// Custom enhancements
|
151
|
+
setTimeout(function() {
|
152
|
+
// Add custom styling or behavior after UI loads
|
153
|
+
const infoSection = document.querySelector('.swagger-ui .info');
|
154
|
+
if (infoSection && !infoSection.querySelector('.rapitapir-badge')) {
|
155
|
+
const badge = document.createElement('div');
|
156
|
+
badge.className = 'rapitapir-badge';
|
157
|
+
badge.innerHTML = '<small style="color: #666; font-style: italic;">Generated with RapiTapir Sinatra Extension</small>';
|
158
|
+
infoSection.appendChild(badge);
|
159
|
+
}
|
160
|
+
}, 1000);
|
161
|
+
};
|
162
|
+
JS
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sinatra/base'
|
4
|
+
require_relative 'sinatra/extension'
|
5
|
+
|
6
|
+
module RapiTapir
|
7
|
+
# SinatraRapiTapir - A clean base class for RapiTapir APIs
|
8
|
+
#
|
9
|
+
# This class provides the most ergonomic way to create RapiTapir APIs:
|
10
|
+
#
|
11
|
+
# class MyAPI < RapiTapir::SinatraRapiTapir
|
12
|
+
# rapitapir do
|
13
|
+
# info(title: 'My API', version: '1.0.0')
|
14
|
+
# development_defaults!
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# endpoint(
|
18
|
+
# GET('/hello').ok(string_response).build
|
19
|
+
# ) { { message: 'Hello!' } }
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Features automatically included:
|
23
|
+
# - Enhanced HTTP verb DSL (GET, POST, PUT, etc.)
|
24
|
+
# - RapiTapir extension with all features
|
25
|
+
# - Clean inheritance-based setup
|
26
|
+
class SinatraRapiTapir < ::Sinatra::Base
|
27
|
+
# Automatically register the RapiTapir extension
|
28
|
+
register RapiTapir::Sinatra::Extension
|
29
|
+
|
30
|
+
# Include a helpful message for developers
|
31
|
+
configure :development do
|
32
|
+
puts '🚀 Using RapiTapir::SinatraRapiTapir base class'
|
33
|
+
puts '✨ Enhanced HTTP verb DSL automatically available'
|
34
|
+
puts '🔧 Extension features: health checks, CORS, docs, and more'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Also make it available at the top level for convenience
|
40
|
+
SinatraRapiTapir = RapiTapir::SinatraRapiTapir
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Array type for validating array values with element type constraints
|
8
|
+
# Validates arrays with specific element types and size constraints
|
9
|
+
class Array < Base
|
10
|
+
attr_reader :item_type
|
11
|
+
|
12
|
+
def initialize(item_type, min_items: nil, max_items: nil, unique_items: false, **options)
|
13
|
+
@item_type = item_type
|
14
|
+
super(
|
15
|
+
min_items: min_items,
|
16
|
+
max_items: max_items,
|
17
|
+
unique_items: unique_items,
|
18
|
+
**options
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
protected
|
23
|
+
|
24
|
+
def validate_type(value)
|
25
|
+
return ["Expected array, got #{value.class}"] unless value.is_a?(::Array)
|
26
|
+
|
27
|
+
[]
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate_constraints(value)
|
31
|
+
errors = []
|
32
|
+
|
33
|
+
errors.concat(validate_length_constraints(value))
|
34
|
+
errors.concat(validate_uniqueness_constraint(value))
|
35
|
+
errors.concat(validate_item_types(value))
|
36
|
+
|
37
|
+
errors
|
38
|
+
end
|
39
|
+
|
40
|
+
def validate_length_constraints(value)
|
41
|
+
errors = []
|
42
|
+
|
43
|
+
errors.concat(validate_min_items_constraint(value))
|
44
|
+
errors.concat(validate_max_items_constraint(value))
|
45
|
+
|
46
|
+
errors
|
47
|
+
end
|
48
|
+
|
49
|
+
def validate_min_items_constraint(value)
|
50
|
+
return [] unless constraints[:min_items] && value.length < constraints[:min_items]
|
51
|
+
|
52
|
+
["Array length #{value.length} is below minimum #{constraints[:min_items]}"]
|
53
|
+
end
|
54
|
+
|
55
|
+
def validate_max_items_constraint(value)
|
56
|
+
return [] unless constraints[:max_items] && value.length > constraints[:max_items]
|
57
|
+
|
58
|
+
["Array length #{value.length} exceeds maximum #{constraints[:max_items]}"]
|
59
|
+
end
|
60
|
+
|
61
|
+
def validate_uniqueness_constraint(value)
|
62
|
+
return [] unless constraints[:unique_items] && value.uniq.length != value.length
|
63
|
+
|
64
|
+
['Array contains duplicate items but must be unique']
|
65
|
+
end
|
66
|
+
|
67
|
+
def validate_item_types(value)
|
68
|
+
errors = []
|
69
|
+
|
70
|
+
value.each_with_index do |item, index|
|
71
|
+
item_result = item_type.validate(item)
|
72
|
+
next if item_result[:valid]
|
73
|
+
|
74
|
+
item_result[:errors].each do |error|
|
75
|
+
errors << "Item at index #{index}: #{error}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
errors
|
80
|
+
end
|
81
|
+
|
82
|
+
def coerce_value(value)
|
83
|
+
case value
|
84
|
+
when ::Array
|
85
|
+
coerce_array_value(value)
|
86
|
+
when ::String
|
87
|
+
coerce_string_value(value)
|
88
|
+
else
|
89
|
+
coerce_single_value(value)
|
90
|
+
end
|
91
|
+
rescue JSON::ParserError => e
|
92
|
+
raise CoercionError.new(value, 'Array', "Invalid JSON: #{e.message}")
|
93
|
+
rescue StandardError => e
|
94
|
+
raise CoercionError.new(value, 'Array', e.message)
|
95
|
+
end
|
96
|
+
|
97
|
+
def coerce_array_value(value)
|
98
|
+
value.map { |item| item_type.coerce(item) }
|
99
|
+
end
|
100
|
+
|
101
|
+
def coerce_string_value(value)
|
102
|
+
# Try to parse as JSON array
|
103
|
+
require 'json'
|
104
|
+
parsed = JSON.parse(value)
|
105
|
+
raise CoercionError.new(value, 'Array', 'JSON string did not parse to array') unless parsed.is_a?(::Array)
|
106
|
+
|
107
|
+
parsed.map { |item| item_type.coerce(item) }
|
108
|
+
end
|
109
|
+
|
110
|
+
def coerce_single_value(value)
|
111
|
+
# Wrap single value in array
|
112
|
+
[item_type.coerce(value)]
|
113
|
+
end
|
114
|
+
|
115
|
+
def json_type
|
116
|
+
'array'
|
117
|
+
end
|
118
|
+
|
119
|
+
def apply_constraints_to_schema(schema)
|
120
|
+
super
|
121
|
+
apply_array_specific_constraints(schema)
|
122
|
+
end
|
123
|
+
|
124
|
+
def apply_array_specific_constraints(schema)
|
125
|
+
schema[:items] = item_type.to_json_schema
|
126
|
+
apply_size_constraints(schema)
|
127
|
+
apply_uniqueness_constraint(schema)
|
128
|
+
end
|
129
|
+
|
130
|
+
def apply_size_constraints(schema)
|
131
|
+
schema[:minItems] = constraints[:min_items] if constraints[:min_items]
|
132
|
+
schema[:maxItems] = constraints[:max_items] if constraints[:max_items]
|
133
|
+
end
|
134
|
+
|
135
|
+
def apply_uniqueness_constraint(schema)
|
136
|
+
schema[:uniqueItems] = constraints[:unique_items] if constraints[:unique_items]
|
137
|
+
end
|
138
|
+
|
139
|
+
def to_s
|
140
|
+
item_type_str = format_item_type_string
|
141
|
+
constraint_part = format_constraints_string
|
142
|
+
"Array[#{item_type_str}]#{constraint_part}"
|
143
|
+
end
|
144
|
+
|
145
|
+
def format_item_type_string
|
146
|
+
item_type.respond_to?(:to_s) ? item_type.to_s : item_type.class.name
|
147
|
+
end
|
148
|
+
|
149
|
+
def format_constraints_string
|
150
|
+
constraint_strs = build_constraint_strings
|
151
|
+
constraint_strs.empty? ? '' : "(#{constraint_strs.join(', ')})"
|
152
|
+
end
|
153
|
+
|
154
|
+
def build_constraint_strings
|
155
|
+
constraint_strs = []
|
156
|
+
constraint_strs << "min_items: #{constraints[:min_items]}" if constraints[:min_items]
|
157
|
+
constraint_strs << "max_items: #{constraints[:max_items]}" if constraints[:max_items]
|
158
|
+
constraint_strs << "unique: #{constraints[:unique_items]}" if constraints[:unique_items]
|
159
|
+
constraint_strs
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|