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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RapiTapir
6
+ module Types
7
+ # Float type for validating floating-point numbers with range constraints
8
+ # Supports minimum, maximum, and multiple validation rules
9
+ class Float < Base
10
+ protected
11
+
12
+ def validate_type(value)
13
+ return [] if value.is_a?(::Float) || value.is_a?(::Integer)
14
+
15
+ ["Expected number (float or integer), got #{value.class}"]
16
+ end
17
+
18
+ def validate_constraints(value)
19
+ errors = []
20
+ float_value = value.to_f
21
+
22
+ errors.concat(validate_range_constraints(float_value))
23
+ errors.concat(validate_multiple_constraint(float_value))
24
+
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def validate_range_constraints(float_value)
31
+ errors = []
32
+
33
+ errors.concat(validate_minimum_constraints(float_value))
34
+ errors.concat(validate_maximum_constraints(float_value))
35
+
36
+ errors
37
+ end
38
+
39
+ def validate_minimum_constraints(float_value)
40
+ errors = []
41
+
42
+ if constraints[:minimum] && float_value < constraints[:minimum]
43
+ errors << "Value #{float_value} is below minimum #{constraints[:minimum]}"
44
+ end
45
+
46
+ if constraints[:exclusive_minimum] && float_value <= constraints[:exclusive_minimum]
47
+ errors << "Value #{float_value} must be greater than #{constraints[:exclusive_minimum]}"
48
+ end
49
+
50
+ errors
51
+ end
52
+
53
+ def validate_maximum_constraints(float_value)
54
+ errors = []
55
+
56
+ if constraints[:maximum] && float_value > constraints[:maximum]
57
+ errors << "Value #{float_value} exceeds maximum #{constraints[:maximum]}"
58
+ end
59
+
60
+ if constraints[:exclusive_maximum] && float_value >= constraints[:exclusive_maximum]
61
+ errors << "Value #{float_value} must be less than #{constraints[:exclusive_maximum]}"
62
+ end
63
+
64
+ errors
65
+ end
66
+
67
+ def validate_multiple_constraint(float_value)
68
+ errors = []
69
+
70
+ if constraints[:multiple_of] && (float_value % constraints[:multiple_of]) != 0
71
+ errors << "Value #{float_value} is not a multiple of #{constraints[:multiple_of]}"
72
+ end
73
+
74
+ errors
75
+ end
76
+
77
+ def coerce_value(value)
78
+ case value
79
+ when ::Float then value
80
+ when ::Integer then value.to_f
81
+ when ::String then coerce_string_to_float(value)
82
+ when true, false then coerce_boolean_to_float(value)
83
+ else
84
+ coerce_other_to_float(value)
85
+ end
86
+ rescue ArgumentError => e
87
+ raise CoercionError.new(value, 'Float', e.message)
88
+ end
89
+
90
+ def coerce_string_to_float(value)
91
+ Kernel.Float(value.strip)
92
+ end
93
+
94
+ def coerce_boolean_to_float(value)
95
+ value ? 1.0 : 0.0
96
+ end
97
+
98
+ def coerce_other_to_float(value)
99
+ raise CoercionError.new(value, 'Float', 'Value cannot be converted to float') unless value.respond_to?(:to_f)
100
+
101
+ value.to_f
102
+ end
103
+
104
+ def json_type
105
+ 'number'
106
+ end
107
+
108
+ def apply_constraints_to_schema(schema)
109
+ super
110
+ apply_range_constraints_to_schema(schema)
111
+ apply_multiple_constraint_to_schema(schema)
112
+ end
113
+
114
+ def apply_range_constraints_to_schema(schema)
115
+ apply_minimum_constraints_to_schema(schema)
116
+ apply_maximum_constraints_to_schema(schema)
117
+ end
118
+
119
+ def apply_minimum_constraints_to_schema(schema)
120
+ schema[:minimum] = constraints[:minimum] if constraints[:minimum]
121
+ schema[:exclusiveMinimum] = constraints[:exclusive_minimum] if constraints[:exclusive_minimum]
122
+ end
123
+
124
+ def apply_maximum_constraints_to_schema(schema)
125
+ schema[:maximum] = constraints[:maximum] if constraints[:maximum]
126
+ schema[:exclusiveMaximum] = constraints[:exclusive_maximum] if constraints[:exclusive_maximum]
127
+ end
128
+
129
+ def apply_multiple_constraint_to_schema(schema)
130
+ schema[:multipleOf] = constraints[:multiple_of] if constraints[:multiple_of]
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RapiTapir
6
+ module Types
7
+ # Hash type for validating hash/object structures with schema definitions
8
+ # Validates hash keys and values according to defined schemas
9
+ class Hash < Base
10
+ attr_reader :field_types
11
+
12
+ def initialize(field_types = {}, additional_properties: true, **options)
13
+ @field_types = field_types.freeze
14
+ super(
15
+ additional_properties: additional_properties,
16
+ **options
17
+ )
18
+ end
19
+
20
+ protected
21
+
22
+ def validate_type(value)
23
+ return ["Expected hash/object, got #{value.class}"] unless value.is_a?(::Hash)
24
+
25
+ []
26
+ end
27
+
28
+ def validate_constraints(value)
29
+ errors = []
30
+
31
+ errors.concat(validate_defined_fields(value))
32
+ errors.concat(validate_additional_properties(value))
33
+
34
+ errors
35
+ end
36
+
37
+ def validate_defined_fields(value)
38
+ errors = []
39
+
40
+ field_types.each do |field_name, field_type|
41
+ field_value = extract_field_value(value, field_name)
42
+ field_result = field_type.validate(field_value)
43
+ next if field_result[:valid]
44
+
45
+ field_result[:errors].each do |error|
46
+ errors << "Field '#{field_name}': #{error}"
47
+ end
48
+ end
49
+
50
+ errors
51
+ end
52
+
53
+ def validate_additional_properties(value)
54
+ return [] if constraints[:additional_properties]
55
+
56
+ expected_keys = field_types.keys.map { |k| [k, k.to_s, k.to_sym] }.flatten.uniq
57
+ unexpected_keys = value.keys - expected_keys
58
+ return [] if unexpected_keys.empty?
59
+
60
+ ["Unexpected fields: #{unexpected_keys.join(', ')}"]
61
+ end
62
+
63
+ def extract_field_value(value, field_name)
64
+ value[field_name] || value[field_name.to_s] || value[field_name.to_sym]
65
+ end
66
+
67
+ def coerce_value(value)
68
+ case value
69
+ when ::Hash
70
+ coerce_hash_value(value)
71
+ when ::String
72
+ coerce_json_string_value(value)
73
+ else
74
+ raise CoercionError.new(value, 'Hash', 'Value cannot be converted to hash')
75
+ end
76
+ rescue JSON::ParserError => e
77
+ raise CoercionError.new(value, 'Hash', "Invalid JSON: #{e.message}")
78
+ end
79
+
80
+ private
81
+
82
+ def coerce_hash_value(value)
83
+ coerced = {}
84
+
85
+ # Coerce defined fields
86
+ coerce_defined_fields(value, coerced)
87
+
88
+ # Include additional properties if allowed
89
+ coerce_additional_properties(value, coerced) if constraints[:additional_properties]
90
+
91
+ coerced
92
+ end
93
+
94
+ def coerce_defined_fields(value, coerced)
95
+ field_types.each do |field_name, field_type|
96
+ field_value = find_field_value(value, field_name)
97
+ coerced[field_name] = field_type.coerce(field_value) if field_value || !field_type.optional?
98
+ end
99
+ end
100
+
101
+ def find_field_value(value, field_name)
102
+ value[field_name] || value[field_name.to_s] || value[field_name.to_sym]
103
+ end
104
+
105
+ def coerce_additional_properties(value, coerced)
106
+ additional_keys = value.keys - field_types.keys.map { |k| [k, k.to_s, k.to_sym] }.flatten
107
+ additional_keys.each do |key|
108
+ coerced[key] = value[key]
109
+ end
110
+ end
111
+
112
+ def coerce_json_string_value(value)
113
+ # Try to parse as JSON
114
+ require 'json'
115
+ parsed = JSON.parse(value)
116
+ raise CoercionError.new(value, 'Hash', 'JSON string did not parse to hash') unless parsed.is_a?(::Hash)
117
+
118
+ coerce_value(parsed)
119
+ end
120
+
121
+ def json_type
122
+ 'object'
123
+ end
124
+
125
+ def apply_constraints_to_schema(schema)
126
+ super
127
+ apply_field_types_to_schema(schema)
128
+ apply_additional_properties_constraint(schema)
129
+ end
130
+
131
+ def apply_field_types_to_schema(schema)
132
+ return unless field_types.any?
133
+
134
+ schema[:properties] = build_properties_schema
135
+ required_fields = collect_required_fields
136
+ schema[:required] = required_fields unless required_fields.empty?
137
+ end
138
+
139
+ def build_properties_schema
140
+ field_types.transform_values(&:to_json_schema)
141
+ end
142
+
143
+ def collect_required_fields
144
+ field_types.reject { |_, field_type| field_type.optional? }.keys
145
+ end
146
+
147
+ def apply_additional_properties_constraint(schema)
148
+ schema[:additionalProperties] = constraints[:additional_properties]
149
+ end
150
+
151
+ def to_s
152
+ if field_types.empty?
153
+ 'Hash'
154
+ else
155
+ field_strs = field_types.map { |k, v| "#{k}: #{v}" }
156
+ "Hash{#{field_strs.join(', ')}}"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RapiTapir
6
+ module Types
7
+ # Integer type with numeric constraints
8
+ #
9
+ # Validates integer values with optional constraints for minimum/maximum values,
10
+ # exclusive bounds, and multiple-of validation.
11
+ #
12
+ # @example Basic integer
13
+ # RapiTapir::Types.integer
14
+ #
15
+ # @example Integer with constraints
16
+ # RapiTapir::Types.integer(minimum: 0, maximum: 100, multiple_of: 5)
17
+ class Integer < Base
18
+ protected
19
+
20
+ def validate_type(value)
21
+ return ["Expected integer, got #{value.class}"] unless value.is_a?(::Integer)
22
+
23
+ []
24
+ end
25
+
26
+ def validate_constraints(value)
27
+ errors = []
28
+
29
+ errors.concat(validate_range_constraints(value))
30
+ errors.concat(validate_multiple_constraint(value))
31
+
32
+ errors
33
+ end
34
+
35
+ private
36
+
37
+ def validate_range_constraints(value)
38
+ errors = []
39
+
40
+ errors.concat(validate_minimum_constraints(value))
41
+ errors.concat(validate_maximum_constraints(value))
42
+
43
+ errors
44
+ end
45
+
46
+ def validate_minimum_constraints(value)
47
+ errors = []
48
+
49
+ if constraints[:minimum] && value < constraints[:minimum]
50
+ errors << "Value #{value} is below minimum #{constraints[:minimum]}"
51
+ end
52
+
53
+ if constraints[:exclusive_minimum] && value <= constraints[:exclusive_minimum]
54
+ errors << "Value #{value} must be greater than #{constraints[:exclusive_minimum]}"
55
+ end
56
+
57
+ errors
58
+ end
59
+
60
+ def validate_maximum_constraints(value)
61
+ errors = []
62
+
63
+ if constraints[:maximum] && value > constraints[:maximum]
64
+ errors << "Value #{value} exceeds maximum #{constraints[:maximum]}"
65
+ end
66
+
67
+ if constraints[:exclusive_maximum] && value >= constraints[:exclusive_maximum]
68
+ errors << "Value #{value} must be less than #{constraints[:exclusive_maximum]}"
69
+ end
70
+
71
+ errors
72
+ end
73
+
74
+ def validate_multiple_constraint(value)
75
+ errors = []
76
+
77
+ if constraints[:multiple_of] && (value % constraints[:multiple_of]) != 0
78
+ errors << "Value #{value} is not a multiple of #{constraints[:multiple_of]}"
79
+ end
80
+
81
+ errors
82
+ end
83
+
84
+ def coerce_value(value)
85
+ case value
86
+ when ::Integer then value
87
+ when ::Float then value.to_i
88
+ when ::String then coerce_string_to_integer(value)
89
+ when true, false then coerce_boolean_to_integer(value)
90
+ else
91
+ coerce_other_to_integer(value)
92
+ end
93
+ rescue ArgumentError => e
94
+ raise CoercionError.new(value, 'Integer', e.message)
95
+ end
96
+
97
+ def coerce_string_to_integer(value)
98
+ Kernel.Integer(value.strip)
99
+ end
100
+
101
+ def coerce_boolean_to_integer(value)
102
+ value ? 1 : 0
103
+ end
104
+
105
+ def coerce_other_to_integer(value)
106
+ unless value.respond_to?(:to_i)
107
+ raise CoercionError.new(value, 'Integer', 'Value cannot be converted to integer')
108
+ end
109
+
110
+ value.to_i
111
+ end
112
+
113
+ def json_type
114
+ 'integer'
115
+ end
116
+
117
+ def apply_constraints_to_schema(schema)
118
+ super
119
+ apply_range_constraints_to_schema(schema)
120
+ apply_multiple_constraint_to_schema(schema)
121
+ end
122
+
123
+ def apply_range_constraints_to_schema(schema)
124
+ apply_minimum_constraints(schema)
125
+ apply_maximum_constraints(schema)
126
+ end
127
+
128
+ def apply_minimum_constraints(schema)
129
+ schema[:minimum] = constraints[:minimum] if constraints[:minimum]
130
+ schema[:exclusiveMinimum] = constraints[:exclusive_minimum] if constraints[:exclusive_minimum]
131
+ end
132
+
133
+ def apply_maximum_constraints(schema)
134
+ schema[:maximum] = constraints[:maximum] if constraints[:maximum]
135
+ schema[:exclusiveMaximum] = constraints[:exclusive_maximum] if constraints[:exclusive_maximum]
136
+ end
137
+
138
+ def apply_multiple_constraint_to_schema(schema)
139
+ schema[:multipleOf] = constraints[:multiple_of] if constraints[:multiple_of]
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RapiTapir
6
+ module Types
7
+ # Object type for validating complex object structures with typed properties
8
+ # Defines objects with named properties and their respective types
9
+ class Object < Base
10
+ attr_reader :fields
11
+
12
+ def initialize(**options, &block)
13
+ @fields = {}
14
+ super(**options)
15
+
16
+ return unless block_given?
17
+
18
+ builder = ObjectBuilder.new(self)
19
+ builder.instance_eval(&block)
20
+ end
21
+
22
+ def field(name, type, required: true, **options)
23
+ field_type = required ? type : ::RapiTapir::Types::Optional.new(type)
24
+ field_type = field_type.with_metadata(**options) if options.any?
25
+ @fields[name.to_sym] = field_type
26
+ self
27
+ end
28
+
29
+ def required_field(name, type, **options)
30
+ field(name, type, required: true, **options)
31
+ end
32
+
33
+ def optional_field(name, type, **options)
34
+ field(name, type, required: false, **options)
35
+ end
36
+
37
+ protected
38
+
39
+ def validate_type(value)
40
+ return ["Expected hash/object, got #{value.class}"] unless value.is_a?(::Hash)
41
+
42
+ []
43
+ end
44
+
45
+ def validate_constraints(value)
46
+ errors = []
47
+
48
+ # Validate each defined field
49
+ fields.each do |field_name, field_type|
50
+ field_value = extract_field_value(value, field_name)
51
+
52
+ field_result = field_type.validate(field_value)
53
+ next if field_result[:valid]
54
+
55
+ field_result[:errors].each do |error|
56
+ errors << "Field '#{field_name}': #{error}"
57
+ end
58
+ end
59
+
60
+ errors
61
+ end
62
+
63
+ def coerce_value(value)
64
+ case value
65
+ when ::Hash
66
+ coerce_hash_to_object(value)
67
+ when ::String
68
+ coerce_json_string_to_object(value)
69
+ else
70
+ raise CoercionError.new(value, 'Object', 'Value cannot be converted to object')
71
+ end
72
+ rescue JSON::ParserError => e
73
+ raise CoercionError.new(value, 'Object', "Invalid JSON: #{e.message}")
74
+ end
75
+
76
+ private
77
+
78
+ def coerce_hash_to_object(value)
79
+ coerced = {}
80
+
81
+ # Coerce each defined field
82
+ fields.each do |field_name, field_type|
83
+ field_value = extract_field_value(value, field_name)
84
+ coerced[field_name] = field_type.coerce(field_value) if field_value || !field_type.optional?
85
+ end
86
+
87
+ coerced
88
+ end
89
+
90
+ def coerce_json_string_to_object(value)
91
+ # Try to parse as JSON
92
+ require 'json'
93
+ parsed = JSON.parse(value)
94
+ raise CoercionError.new(value, 'Object', 'JSON string did not parse to object') unless parsed.is_a?(::Hash)
95
+
96
+ coerce_value(parsed)
97
+ end
98
+
99
+ def json_type
100
+ 'object'
101
+ end
102
+
103
+ def apply_constraints_to_schema(schema)
104
+ super
105
+
106
+ if fields.any?
107
+ schema[:properties] = {}
108
+ required_fields = []
109
+
110
+ fields.each do |field_name, field_type|
111
+ schema[:properties][field_name] = field_type.to_json_schema
112
+ required_fields << field_name unless field_type.optional?
113
+ end
114
+
115
+ schema[:required] = required_fields unless required_fields.empty?
116
+ end
117
+
118
+ schema[:additionalProperties] = false # Objects are strict by default
119
+ end
120
+
121
+ def to_s
122
+ if fields.empty?
123
+ 'Object'
124
+ else
125
+ field_strs = fields.map { |name, type| "#{name}: #{type}" }
126
+ "Object{#{field_strs.join(', ')}}"
127
+ end
128
+ end
129
+
130
+ def extract_field_value(hash, field_name)
131
+ # Try different key formats: symbol, string, and string version of symbol
132
+ hash[field_name] || hash[field_name.to_s] || hash[field_name.to_sym]
133
+ end
134
+
135
+ # Builder for constructing Object type definitions
136
+ # Provides a fluent interface for defining object properties
137
+ class ObjectBuilder
138
+ def initialize(object_type)
139
+ @object_type = object_type
140
+ end
141
+
142
+ def field(name, type, required: true, **options)
143
+ @object_type.field(name, type, required: required, **options)
144
+ end
145
+
146
+ def required_field(name, type, **options)
147
+ @object_type.required_field(name, type, **options)
148
+ end
149
+
150
+ def optional_field(name, type, **options)
151
+ @object_type.optional_field(name, type, **options)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RapiTapir
6
+ module Types
7
+ # Optional type wrapper for nullable values
8
+ # Wraps other types to allow nil values in addition to the wrapped type
9
+ class Optional < Base
10
+ attr_reader :wrapped_type
11
+
12
+ def initialize(wrapped_type)
13
+ @wrapped_type = wrapped_type
14
+ super(optional: true)
15
+ end
16
+
17
+ def validate(value)
18
+ # Optional types allow nil
19
+ return validation_result(true, []) if value.nil?
20
+
21
+ # Delegate to wrapped type
22
+ wrapped_type.validate(value)
23
+ end
24
+
25
+ def coerce(value)
26
+ return nil if value.nil?
27
+
28
+ wrapped_type.coerce(value)
29
+ end
30
+
31
+ def required?
32
+ false
33
+ end
34
+
35
+ def optional?
36
+ true
37
+ end
38
+
39
+ def to_json_schema
40
+ wrapped_type.to_json_schema
41
+ # Optional types are handled by not including them in the required array
42
+ # at the parent level, so we don't need to modify the schema here
43
+ end
44
+
45
+ def to_s
46
+ "Optional[#{wrapped_type}]"
47
+ end
48
+
49
+ def with_metadata(**meta)
50
+ # Delegate metadata to wrapped type but maintain Optional wrapper
51
+ Optional.new(wrapped_type.with_metadata(**meta))
52
+ end
53
+
54
+ private
55
+
56
+ def validation_result(valid, errors)
57
+ {
58
+ valid: valid,
59
+ errors: errors,
60
+ value_errors: errors.empty? ? [] : [ValidationError.new(nil, self, errors)]
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end