grape-oas 1.0.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a578224c8df0f07ef5151db8496a084a64c6000f1f4c4158d7cc21efee1f91f
4
- data.tar.gz: a1427ab277998cd7828e24c91f2555be6ea41b254312f14c4ad40b43204fa13c
3
+ metadata.gz: 54679fa806e8eae3cbff18605ccd3dd7e188e7a2cf774d92ea233172fefc94ef
4
+ data.tar.gz: ee59342a9d6c9be4bb79cf081beaaf833fae65bdfbaf12b619765eb37d1119a8
5
5
  SHA512:
6
- metadata.gz: c03fa30c214a62b2f3164ced48ff16e6c8ba120126ac3d7397db0cd0a7e6ee191224b10bd59badb91f848d3bfcded18cc03e4da331f883c80203795dfa0a143b
7
- data.tar.gz: b232aa12c2211dc35853933ad09e1d1d5451d1df9fc3c2e1cb2c3bc1d58f8769b892141381323669c3c158e6ca409bbab3358012f9be0360f2736cb6194b1ada
6
+ metadata.gz: 21f0ab789f3812daa0fa3971d2bb9ad9693351ee2178c1a2ac9c3f3e706f51023c1bf67bdb220098a74af19cd3eb90c42521a8bdc9992ef7af8594089e58c897
7
+ data.tar.gz: b6eef41e2c1eac91c3c41aa4e225f55a7650908d64133ec816e1e2c7bb3461282146020e6a181cc30156b85d11259ea2ae3aee50184e8b471420dbf0cd7df2ec
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+
9
+ ## [1.0.1] - 2025-12-15
10
+
11
+ ### Fixes
12
+
13
+ - [#8](https://github.com/numbata/grape-oas/pull/8): Add OAS2 parameter schema constraint export with enum normalization and retain zero-valued constraints across OAS exporters. - [@numbata](https://github.com/numbata).
14
+ - [#9](https://github.com/numbata/grape-oas/pull/9): Treat GET/HEAD/DELETE as bodyless by default via shared constants and tests - [@numbata](https://github.com/numbata).
15
+ - [#10](https://github.com/numbata/grape-oas/pull/10): Add grape-swagger compatible `in:` location syntax for parameters alongside `param_type` - [@numbata](https://github.com/numbata).
16
+ - [#11](https://github.com/numbata/grape-oas/pull/11): Flatten nested Hash params to bracket-notation query params for GET/HEAD/DELETE requests - [@numbata](https://github.com/numbata).
17
+ - [#12](https://github.com/numbata/grape-oas/pull/12): Add fallback to `spec[:desc]` for parameter descriptions when `documentation[:desc]` is not set - [@numbata](https://github.com/numbata).
18
+
8
19
  ## [1.0.0] - 2025-12-06
9
20
 
10
21
  ### Added
data/RELEASING.md CHANGED
@@ -80,7 +80,7 @@ git push origin main
80
80
 
81
81
  This task will:
82
82
  - Bump the patch version in `lib/grape_oas/version.rb`
83
- - Ensure CHANGELOG.md has an "Unreleased" section
83
+ - Ensure CHANGELOG.md has an "Unreleased" section with "* Your contribution here" placeholder
84
84
  - Commit the changes with message "Prepare for next development iteration"
85
85
 
86
86
  **Note**: The task is idempotent - safe to run multiple times without duplicating work.
@@ -89,7 +89,7 @@ This task will:
89
89
  <summary>Manual Post-Release Steps (if needed)</summary>
90
90
 
91
91
  1. **Prepare for next development cycle**:
92
- - Add "Unreleased" section to CHANGELOG.md
92
+ - Add "Unreleased" section to CHANGELOG.md with "* Your contribution here" placeholder
93
93
  - Bump version in `lib/grape_oas/version.rb`
94
94
 
95
95
  2. **Commit post-release changes**:
data/grape-oas.gemspec CHANGED
@@ -14,14 +14,18 @@ Gem::Specification.new do |spec|
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 3.2"
16
16
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = spec.homepage
19
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
17
+ spec.metadata = {
18
+ "homepage_uri" => spec.homepage,
19
+ "source_code_uri" => spec.homepage,
20
+ "github_repo" => "https://github.com/numbata/grape-oas",
21
+ "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
22
+ "documentation_uri" => "#{spec.homepage}#readme",
23
+ "bug_tracker_uri" => "#{spec.homepage}/issues",
24
+ "rubygems_mfa_required" => "true",
25
+ }
20
26
 
21
27
  spec.files = Dir["lib/**/*", "*.md", "LICENSE.txt", "grape-oas.gemspec"]
22
28
 
23
29
  spec.add_dependency "grape", ">= 3.0"
24
30
  spec.add_dependency "zeitwerk"
25
-
26
- spec.metadata["rubygems_mfa_required"] = "true"
27
31
  end
@@ -36,7 +36,7 @@ module GrapeOAS
36
36
  # OAS spec says GET/HEAD/DELETE "MAY ignore" request bodies
37
37
  # Skip by default unless explicitly allowed via documentation option
38
38
  http_method = operation.http_method.to_s.downcase
39
- if %w[get head delete].include?(http_method)
39
+ if Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
40
40
  allow_body = route.options.dig(:documentation, :request_body) ||
41
41
  route.options[:request_body]
42
42
  return unless allow_body
@@ -71,17 +71,34 @@ module GrapeOAS
71
71
  end
72
72
 
73
73
  # Extracts non-body params (path, query, header) from flat params.
74
+ # For non-body HTTP methods (GET, HEAD, DELETE), also includes nested params
75
+ # as flat query parameters with bracket notation (e.g., "tax_id[type]"),
76
+ # unless request_body is explicitly enabled.
74
77
  def extract_non_body_params(all_params, route_params)
75
78
  params = []
79
+ http_method = route.request_method.to_s.downcase
80
+ flatten_nested = should_flatten_nested_to_query?(http_method, all_params)
76
81
 
77
82
  all_params.each do |name, spec|
78
- # Skip nested params (they go into body)
79
- next if name.include?("[")
80
- # Skip Hash/body params
81
- next if location_resolver.body_param?(spec)
82
83
  # Skip hidden params
83
84
  next if location_resolver.hidden_parameter?(spec)
84
85
 
86
+ is_nested = name.include?("[")
87
+ is_hash_param = location_resolver.body_param?(spec)
88
+
89
+ # For nested bracket params (e.g., "tax_id[type]"), include as query params
90
+ # for non-body HTTP methods (unless request_body is explicitly enabled)
91
+ if is_nested
92
+ next unless flatten_nested
93
+
94
+ params << build_parameter(name, "query", spec[:required] || false, schema_builder.build(spec), spec)
95
+ next
96
+ end
97
+
98
+ # Skip Hash type params (they're handled via nested bracket params above
99
+ # or via body schema for POST/PUT/PATCH)
100
+ next if is_hash_param
101
+
85
102
  location = location_resolver.resolve(
86
103
  name: name,
87
104
  spec: spec,
@@ -97,13 +114,35 @@ module GrapeOAS
97
114
  params
98
115
  end
99
116
 
117
+ # Determines whether nested params should be flattened to query params.
118
+ # Returns true for GET/HEAD/DELETE unless body is explicitly requested via:
119
+ # - route-level `request_body: true` option
120
+ # - any parameter with `documentation: { in: 'body' }` or `documentation: { param_type: 'body' }`
121
+ def should_flatten_nested_to_query?(http_method, all_params)
122
+ return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
123
+
124
+ # If request_body is explicitly enabled at route level, use body schema
125
+ return false if route.options.dig(:documentation, :request_body) || route.options[:request_body]
126
+
127
+ # If any parameter is explicitly marked as body, use body schema
128
+ has_explicit_body_param = all_params.any? do |name, spec|
129
+ next false if name.include?("[") # Skip bracket params, check parent Hash params only
130
+
131
+ param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
132
+ in_location = spec.dig(:documentation, :in)&.to_s&.downcase
133
+ param_type == "body" || in_location == "body"
134
+ end
135
+
136
+ !has_explicit_body_param
137
+ end
138
+
100
139
  def build_parameter(name, location, required, schema, spec)
101
140
  ApiModel::Parameter.new(
102
141
  location: location,
103
142
  name: name,
104
143
  required: required,
105
144
  schema: schema,
106
- description: spec[:documentation]&.dig(:desc),
145
+ description: spec[:documentation]&.dig(:desc) || spec[:desc],
107
146
  collection_format: extract_collection_format(spec),
108
147
  )
109
148
  end
@@ -19,20 +19,28 @@ module GrapeOAS
19
19
  end
20
20
 
21
21
  # Checks if a parameter should be in the request body.
22
+ # Supports both `param_type: 'body'` and `in: 'body'` for grape-swagger compatibility.
22
23
  #
23
24
  # @param spec [Hash] the parameter specification
24
25
  # @return [Boolean] true if it's a body parameter
25
26
  def self.body_param?(spec)
26
- spec.dig(:documentation, :param_type) == "body" || [Hash, "Hash"].include?(spec[:type])
27
+ param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
28
+ in_location = spec.dig(:documentation, :in)&.to_s&.downcase
29
+
30
+ param_type == "body" || in_location == "body" || [Hash, "Hash"].include?(spec[:type])
27
31
  end
28
32
 
29
33
  # Checks if a parameter is explicitly marked as NOT a body param.
34
+ # Supports both `param_type` and `in` for grape-swagger compatibility.
30
35
  #
31
36
  # @param spec [Hash] the parameter specification
32
37
  # @return [Boolean] true if explicitly non-body
33
38
  def self.explicit_non_body_param?(spec)
34
39
  param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
35
- param_type && %w[query header path].include?(param_type)
40
+ in_location = spec.dig(:documentation, :in)&.to_s&.downcase
41
+ location = param_type || in_location
42
+
43
+ location && %w[query header path].include?(location)
36
44
  end
37
45
 
38
46
  # Checks if a parameter should be hidden from documentation.
@@ -51,11 +59,29 @@ module GrapeOAS
51
59
  class << self
52
60
  private
53
61
 
62
+ # Extracts the parameter location from the specification.
63
+ # Supports both `param_type` and `in` options for grape-swagger compatibility.
64
+ #
65
+ # Precedence (highest to lowest):
66
+ # 1. `param_type` option (e.g., `documentation: { param_type: 'query' }`)
67
+ # 2. `in` option (e.g., `documentation: { in: 'query' }`)
68
+ # 3. Falls back to "query" if neither is specified
69
+ #
70
+ # Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
71
+ # For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
72
+ #
73
+ # @param spec [Hash] the parameter specification
74
+ # @param route [Object] the Grape route object
75
+ # @return [String] the parameter location
54
76
  def extract_from_spec(spec, route)
55
77
  # If body_name is set on the route, treat non-path params as body by default
56
- return "body" if route.options[:body_name] && !spec.dig(:documentation, :param_type)
78
+ param_type = spec.dig(:documentation, :param_type)
79
+ in_location = spec.dig(:documentation, :in)
80
+ return "body" if route.options[:body_name] && !param_type && !in_location
57
81
 
58
- spec.dig(:documentation, :param_type)&.downcase || "query"
82
+ # Support both param_type and in for grape-swagger compatibility
83
+ # param_type takes precedence over in when both are specified
84
+ (param_type || in_location)&.to_s&.downcase || "query"
59
85
  end
60
86
  end
61
87
  end
@@ -16,6 +16,15 @@ module GrapeOAS
16
16
  ALL = [STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY, FILE].freeze
17
17
  end
18
18
 
19
+ # HTTP method-related constants
20
+ module HttpMethods
21
+ # HTTP methods that typically don't have request bodies.
22
+ # Per RFC 7231, GET/HEAD/DELETE semantics don't define body behavior,
23
+ # but many implementations ignore them. These are treated specially
24
+ # when generating OpenAPI specs.
25
+ BODYLESS_HTTP_METHODS = %w[get head delete].freeze
26
+ end
27
+
19
28
  # Common MIME types
20
29
  module MimeTypes
21
30
  JSON = "application/json"
@@ -34,7 +34,7 @@ module GrapeOAS
34
34
  def build_parameter(param)
35
35
  type = param.schema&.type
36
36
  format = param.schema&.format
37
- primitive_types = PRIMITIVE_MAPPINGS.keys + %w[object string boolean file json array]
37
+ primitive_types = PRIMITIVE_MAPPINGS.keys + %w[object string boolean file json array number]
38
38
  is_primitive = type && primitive_types.include?(type)
39
39
 
40
40
  if is_primitive && param.location != "body"
@@ -47,6 +47,7 @@ module GrapeOAS
47
47
  "type" => mapping ? mapping[:type] : type,
48
48
  "format" => format || (mapping ? mapping[:format] : nil)
49
49
  }
50
+ apply_schema_constraints(result, param.schema)
50
51
  apply_collection_format(result, param, type)
51
52
  result.compact
52
53
  else
@@ -63,6 +64,38 @@ module GrapeOAS
63
64
  end
64
65
  end
65
66
 
67
+ def apply_schema_constraints(result, schema)
68
+ return unless schema
69
+
70
+ result["minimum"] = schema.minimum if schema.respond_to?(:minimum) && !schema.minimum.nil?
71
+ result["maximum"] = schema.maximum if schema.respond_to?(:maximum) && !schema.maximum.nil?
72
+ result["exclusiveMinimum"] = schema.exclusive_minimum if schema.respond_to?(:exclusive_minimum) && schema.exclusive_minimum
73
+ result["exclusiveMaximum"] = schema.exclusive_maximum if schema.respond_to?(:exclusive_maximum) && schema.exclusive_maximum
74
+ result["minLength"] = schema.min_length if schema.respond_to?(:min_length) && !schema.min_length.nil?
75
+ result["maxLength"] = schema.max_length if schema.respond_to?(:max_length) && !schema.max_length.nil?
76
+ result["minItems"] = schema.min_items if schema.respond_to?(:min_items) && !schema.min_items.nil?
77
+ result["maxItems"] = schema.max_items if schema.respond_to?(:max_items) && !schema.max_items.nil?
78
+ result["pattern"] = schema.pattern if schema.respond_to?(:pattern) && schema.pattern
79
+ result["enum"] = normalize_enum(schema.enum, result["type"]) if schema.respond_to?(:enum) && schema.enum
80
+ end
81
+
82
+ def normalize_enum(enum_vals, type)
83
+ return nil unless enum_vals.is_a?(Array)
84
+
85
+ coerced = enum_vals.map do |v|
86
+ case type
87
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
88
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
89
+ else v
90
+ end
91
+ end.compact
92
+
93
+ result = coerced.uniq
94
+ return nil if result.empty?
95
+
96
+ result
97
+ end
98
+
66
99
  def apply_collection_format(result, param, type)
67
100
  return unless type == Constants::SchemaTypes::ARRAY
68
101
  return unless param.collection_format
@@ -167,18 +167,18 @@ module GrapeOAS
167
167
  end
168
168
 
169
169
  def apply_numeric_constraints(hash)
170
- hash["minimum"] = @schema.minimum if @schema.minimum
171
- hash["maximum"] = @schema.maximum if @schema.maximum
170
+ hash["minimum"] = @schema.minimum unless @schema.minimum.nil?
171
+ hash["maximum"] = @schema.maximum unless @schema.maximum.nil?
172
172
 
173
173
  if @nullable_keyword
174
174
  hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
175
175
  hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
176
176
  else
177
- if @schema.exclusive_minimum && @schema.minimum
177
+ if @schema.exclusive_minimum && !@schema.minimum.nil?
178
178
  hash["exclusiveMinimum"] = @schema.minimum
179
179
  hash.delete("minimum")
180
180
  end
181
- if @schema.exclusive_maximum && @schema.maximum
181
+ if @schema.exclusive_maximum && !@schema.maximum.nil?
182
182
  hash["exclusiveMaximum"] = @schema.maximum
183
183
  hash.delete("maximum")
184
184
  end
@@ -186,14 +186,14 @@ module GrapeOAS
186
186
  end
187
187
 
188
188
  def apply_string_constraints(hash)
189
- hash["minLength"] = @schema.min_length if @schema.min_length
190
- hash["maxLength"] = @schema.max_length if @schema.max_length
189
+ hash["minLength"] = @schema.min_length unless @schema.min_length.nil?
190
+ hash["maxLength"] = @schema.max_length unless @schema.max_length.nil?
191
191
  hash["pattern"] = @schema.pattern if @schema.pattern
192
192
  end
193
193
 
194
194
  def apply_array_constraints(hash)
195
- hash["minItems"] = @schema.min_items if @schema.min_items
196
- hash["maxItems"] = @schema.max_items if @schema.max_items
195
+ hash["minItems"] = @schema.min_items unless @schema.min_items.nil?
196
+ hash["maxItems"] = @schema.max_items unless @schema.max_items.nil?
197
197
  end
198
198
 
199
199
  # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-oas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Subbota
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: grape
@@ -130,8 +131,12 @@ licenses:
130
131
  metadata:
131
132
  homepage_uri: https://github.com/numbata/grape-oas
132
133
  source_code_uri: https://github.com/numbata/grape-oas
134
+ github_repo: https://github.com/numbata/grape-oas
133
135
  changelog_uri: https://github.com/numbata/grape-oas/blob/main/CHANGELOG.md
136
+ documentation_uri: https://github.com/numbata/grape-oas#readme
137
+ bug_tracker_uri: https://github.com/numbata/grape-oas/issues
134
138
  rubygems_mfa_required: 'true'
139
+ post_install_message:
135
140
  rdoc_options: []
136
141
  require_paths:
137
142
  - lib
@@ -146,7 +151,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
151
  - !ruby/object:Gem::Version
147
152
  version: '0'
148
153
  requirements: []
149
- rubygems_version: 3.6.8
154
+ rubygems_version: 3.5.22
155
+ signing_key:
150
156
  specification_version: 4
151
157
  summary: OpenAPI (Swagger) v2 and v3 documentation for Grape APIs
152
158
  test_files: []