grape_openapi3 0.1.3 → 0.1.5

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: 3808df464b57163b3cf5adffec3a2b2bda782648d1ee9ad1731b5fbdf0a64fc5
4
- data.tar.gz: a7687ffba660a3093fa22f318b0c586957532fe376c82fafc213e0cd1f947a11
3
+ metadata.gz: c028381878b52f6a689b843e5e0b4aa37799bc26fc612316b51a6617783f4afa
4
+ data.tar.gz: b72d1e69efff1e6575cb72c2d1fae4c2a09d5a908f5e809a7c3b8303f681c4b8
5
5
  SHA512:
6
- metadata.gz: b123656dde3a8542bad0647080a74c80d6e1a3a80efa73cfb850298ced9436776e016abcb6a94a7e67901f5b70620840171d9d54b73ae87227acbe45949d5a73
7
- data.tar.gz: 4b31be25b8a836a6fbd740f66b8e74826bf6f9511e79783d5bc15cc6bbd818a8e83ae853bc88ee6bc574f208972ca6b0d120a91860ba2f9b375bc632bcadf737
6
+ metadata.gz: 7908a7ac38deeeda95246ee30026f82b9f3c872ff26c4c8863de6b2f9a6157963d5230235bc616593b761603ce3291ee4608711417e88085ff5430f99e4f6c17
7
+ data.tar.gz: '089da884741c789d424e8e11f55814b20ef2025567ba08b9c9f4a7231e4980fd306d5e6d621b8fcec1490faa0bcedc278b4b1325a1696a627966ae650d44316a'
@@ -4,15 +4,21 @@ module GrapeOpenapi3
4
4
  module Builders
5
5
  # Converts a route's params hash into OpenAPI 3.0 `parameters` + `requestBody`.
6
6
  #
7
- # Param location is determined in this priority order:
7
+ # Param location is determined (per top-level param) in this priority order:
8
8
  # 1. Explicit: documentation: { param_type: "path|query|header|body|formData" }
9
9
  # 2. Name matches a {segment} in the path → path
10
10
  # 3. type: File → formData (multipart)
11
11
  # 4. HTTP method GET/DELETE → query
12
12
  # 5. HTTP method POST/PUT/PATCH → body (requestBody JSON)
13
13
  #
14
- # Params marked as formData OR containing a File field produce a
15
- # multipart/form-data requestBody. All other body params produce JSON.
14
+ # Nested params:
15
+ # Grape flattens nested params into bracketed keys, e.g.
16
+ # "address" => { type: "Hash" }
17
+ # "address[street]"=> { type: "String", required: true }
18
+ # "tags" => { type: "Array" }
19
+ # "tags[label]" => { type: "String" }
20
+ # These are reassembled into nested object / array-of-object schemas in the
21
+ # request body. Location is decided by the TOP-LEVEL key; children inherit it.
16
22
  class ParameterBuilder
17
23
  BODY_METHODS = %w[post put patch].freeze
18
24
  PATH_SEGMENT = /\{(\w+)\}/
@@ -29,22 +35,20 @@ module GrapeOpenapi3
29
35
  end
30
36
 
31
37
  def call
32
- inline = [] # path / query / header params
33
- json = {} # body params → application/json requestBody
34
- formdata = {} # formData params → multipart/form-data requestBody
38
+ inline = [] # path / query / header params (flat)
39
+ json = {} # body params (full bracket keys) → application/json
40
+ formdata = {} # formData params → multipart/form-data
35
41
 
36
- @route[:params].each do |name, definition|
37
- name_s = name.to_s.sub(/\[\]$/, "") # strip Grape's array brackets
38
- defn = definition.is_a?(Hash) ? definition : {}
39
- loc = location(name_s, defn)
42
+ grouped_by_top.each do |top, info|
43
+ loc = location(top, info[:root] || {})
40
44
 
41
45
  case loc
42
46
  when :path, :query, :header
43
- inline << build_parameter(name_s, defn, loc)
47
+ inline << build_parameter(top, info[:root] || {}, loc)
44
48
  when :form
45
- formdata[name_s] = defn
49
+ formdata.merge!(info[:entries])
46
50
  when :body
47
- json[name_s] = defn
51
+ json.merge!(info[:entries])
48
52
  end
49
53
  end
50
54
 
@@ -56,6 +60,27 @@ module GrapeOpenapi3
56
60
 
57
61
  private
58
62
 
63
+ # Group every param entry under its top-level name, keeping the full bracket
64
+ # keys so the body schema can be rebuilt with nesting.
65
+ def grouped_by_top
66
+ @route[:params].each_with_object({}) do |(name, definition), acc|
67
+ key = name.to_s
68
+ segments = bracket_segments(key)
69
+ top = segments.first
70
+ next if top.nil? || top.empty?
71
+
72
+ defn = definition.is_a?(Hash) ? definition : {}
73
+ acc[top] ||= { entries: {}, root: nil }
74
+ acc[top][:entries][key] = defn
75
+ acc[top][:root] = defn if segments.length == 1
76
+ end
77
+ end
78
+
79
+ # "address[street]" → ["address", "street"]; "tags[]" → ["tags"]; "id" → ["id"]
80
+ def bracket_segments(key)
81
+ key.scan(/[^\[\]]+/)
82
+ end
83
+
59
84
  def location(name, defn)
60
85
  # Path segments are structural — they always win over any explicit hint.
61
86
  return :path if @path_names.include?(name)
@@ -83,9 +108,7 @@ module GrapeOpenapi3
83
108
  end
84
109
 
85
110
  def build_parameter(name, defn, loc)
86
- schema = TypeMapper.call(defn[:type] || String)
87
- schema["enum"] = Array(defn[:values]) if defn[:values]
88
- schema["default"] = defn[:default] unless defn[:default].nil?
111
+ schema = leaf_schema(defn, nullable: false)
89
112
 
90
113
  param = {
91
114
  "name" => name,
@@ -103,39 +126,92 @@ module GrapeOpenapi3
103
126
  def build_request_body(json, formdata)
104
127
  return nil if json.empty? && formdata.empty?
105
128
 
106
- all = formdata.merge(json)
107
-
108
129
  if formdata.any?
109
- build_body("multipart/form-data", all)
130
+ build_body("multipart/form-data", formdata.merge(json))
110
131
  else
111
- build_body("application/json", all)
132
+ build_body("application/json", json)
112
133
  end
113
134
  end
114
135
 
115
- def build_body(media_type, params)
136
+ # Build the requestBody object from flat bracket-keyed entries, rebuilding
137
+ # nested objects and arrays-of-objects along the way.
138
+ def build_body(media_type, entries)
139
+ schema = object_schema_from(entries)
140
+ {
141
+ "required" => Array(schema["required"]).any?,
142
+ "content" => { media_type => { "schema" => schema } },
143
+ }
144
+ end
145
+
146
+ # --- nested schema assembly -----------------------------------------
147
+
148
+ def object_schema_from(entries)
149
+ root = { children: {}, defn: nil }
150
+
151
+ entries.each do |key, defn|
152
+ node = root
153
+ bracket_segments(key).each do |seg|
154
+ node[:children][seg] ||= { children: {}, defn: nil }
155
+ node = node[:children][seg]
156
+ end
157
+ node[:defn] = defn
158
+ end
159
+
160
+ node_to_schema(root)
161
+ end
162
+
163
+ def node_to_schema(node)
164
+ return leaf_schema(node[:defn] || {}) if node[:children].empty?
165
+
166
+ object = build_object(node[:children])
167
+ array_container?(node[:defn]) ? wrap_array(object, node[:defn]) : decorate(object, node[:defn])
168
+ end
169
+
170
+ def build_object(children)
116
171
  properties = {}
117
172
  required = []
118
173
 
119
- params.each do |name, defn|
120
- schema = TypeMapper.call(defn[:type] || String)
121
- schema["enum"] = Array(defn[:values]) if defn[:values]
122
- schema["default"] = defn[:default] unless defn[:default].nil?
123
- schema["nullable"] = true unless defn[:required]
174
+ children.each do |name, child|
175
+ properties[name] = node_to_schema(child)
176
+ required << name if child[:defn] && child[:defn][:required]
177
+ end
124
178
 
125
- desc = defn[:desc] || defn[:description]
126
- schema["description"] = desc.to_s if desc
179
+ schema = { "type" => "object", "properties" => properties }
180
+ schema["required"] = required unless required.empty?
181
+ schema
182
+ end
127
183
 
128
- properties[name] = schema
129
- required << name if defn[:required]
130
- end
184
+ def wrap_array(items_schema, defn)
185
+ decorate({ "type" => "array", "items" => items_schema }, defn)
186
+ end
131
187
 
132
- obj_schema = { "type" => "object", "properties" => properties }
133
- obj_schema["required"] = required unless required.empty?
188
+ def array_container?(defn)
189
+ return false unless defn
134
190
 
135
- {
136
- "required" => required.any?,
137
- "content" => { media_type => { "schema" => obj_schema } },
138
- }
191
+ name = defn[:type].is_a?(Class) ? defn[:type].name.to_s : defn[:type].to_s
192
+ name == "Array" || name.match?(/\A\[.*\]\z/)
193
+ end
194
+
195
+ # A scalar/terminal param: map its type and attach enum/default/desc/nullable.
196
+ def leaf_schema(defn, nullable: nil)
197
+ schema = TypeMapper.call(defn[:type] || String)
198
+ decorate(schema, defn, nullable: nullable)
199
+ end
200
+
201
+ # Attach enum/default/description and (for body params) nullable when optional.
202
+ def decorate(schema, defn, nullable: nil)
203
+ return schema unless defn
204
+
205
+ schema["enum"] = Array(defn[:values]) if defn[:values]
206
+ schema["default"] = defn[:default] unless defn[:default].nil?
207
+
208
+ desc = defn[:desc] || defn[:description]
209
+ schema["description"] = desc.to_s if desc
210
+
211
+ nullable = !defn[:required] if nullable.nil?
212
+ schema["nullable"] = true if nullable
213
+
214
+ schema
139
215
  end
140
216
  end
141
217
  end
@@ -1,5 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOpenapi3
4
- Config = Struct.new(:info, :servers, :security_schemes, :security, :tags, keyword_init: true)
5
- end
4
+ # Configuration object passed through the document build.
5
+ #
6
+ # schema_name_separator: joins namespace segments when two entities share the
7
+ # same short name (e.g. V2::Mentors::MenteeResponse vs V2::Kids::MenteeResponse
8
+ # become "Mentors_MenteeResponse" / "Kids_MenteeResponse"). Default "_".
9
+ Config = Struct.new(
10
+ :info, :servers, :security_schemes, :security, :tags, :schema_name_separator,
11
+ keyword_init: true
12
+ ) do
13
+ def schema_name_separator
14
+ self[:schema_name_separator] || "_"
15
+ end
16
+ end
17
+ end
@@ -15,12 +15,15 @@ module GrapeOpenapi3
15
15
  end
16
16
 
17
17
  def build
18
- entity_reader = Readers::EntityReader.new
18
+ entity_reader = Readers::EntityReader.new(separator: @config.schema_name_separator)
19
19
 
20
20
  route_list = @app.routes
21
21
  .map { |r| Readers::RouteReader.call(r) }
22
22
  .compact
23
23
 
24
+ # Pre-scan all reachable entities so schema names are stable and collision-free.
25
+ entity_reader.prepare(route_list.flat_map { |rd| success_entity_classes(rd) })
26
+
24
27
  paths = build_paths(route_list, entity_reader)
25
28
  components = build_components(entity_reader)
26
29
 
@@ -39,6 +42,18 @@ module GrapeOpenapi3
39
42
 
40
43
  private
41
44
 
45
+ # The Grape::Entity class(es) referenced by a route's success response, if any.
46
+ # Handles both the bare-class form and the hash form ({ model: SomeEntity }).
47
+ def success_entity_classes(route_data)
48
+ entity = route_data[:success_entity]
49
+ klass = if entity.is_a?(Class)
50
+ entity
51
+ elsif entity.is_a?(Hash)
52
+ entity[:model] || entity["model"]
53
+ end
54
+ klass.is_a?(Class) ? [klass] : []
55
+ end
56
+
42
57
  def build_paths(route_list, entity_reader)
43
58
  route_list.each_with_object({}) do |route_data, paths|
44
59
  path = route_data[:path]
@@ -9,17 +9,36 @@ module GrapeOpenapi3
9
9
  # - Circular references are broken (placeholder set before recursing)
10
10
  # - Nested entities (using: SomeEntity) become $ref pointers
11
11
  #
12
+ # Schema naming (collision-free):
13
+ # By default each schema is named after the entity's last namespace segment
14
+ # (e.g. "MenteeResponse"). When two DIFFERENT entity classes share that short
15
+ # name, #prepare disambiguates them deterministically using the shortest
16
+ # namespace suffix that makes them unique (e.g. "Mentors_MenteeResponse" vs
17
+ # "Kids_MenteeResponse"). Names are stable regardless of registration order.
18
+ #
12
19
  # Usage:
13
- # reader = EntityReader.new
14
- # name = reader.register(MyEntity) # "MyEntity", adds to schemas
15
- # reader.schemas # → { "MyEntity" => { ... } }
20
+ # reader = EntityReader.new(separator: "_")
21
+ # reader.prepare([RootEntityA, RootEntityB]) # optional but recommended
22
+ # name = reader.register(MyEntity) # → schema name, adds to schemas
23
+ # reader.schemas # → { "MyEntity" => { ... } }
16
24
  class EntityReader
17
25
  REPRESENT_EXPOSURE = "Grape::Entity::Exposure::RepresentExposure"
18
26
 
19
27
  attr_reader :schemas
20
28
 
21
- def initialize
22
- @schemas = {}
29
+ def initialize(separator: "_")
30
+ @schemas = {}
31
+ @separator = separator
32
+ @name_map = {} # full Ruby class name => assigned schema name
33
+ end
34
+
35
+ # Walk the entity graph reachable from the given root entities and assign a
36
+ # stable, collision-free schema name to every entity. Call before #register.
37
+ def prepare(root_entities)
38
+ all = []
39
+ Array(root_entities).each { |e| collect(resolve_class(e), all) }
40
+ @name_map = build_name_map(all)
41
+ self
23
42
  end
24
43
 
25
44
  # Register an entity class and return its schema name.
@@ -38,6 +57,57 @@ module GrapeOpenapi3
38
57
 
39
58
  private
40
59
 
60
+ # --- name assignment -------------------------------------------------
61
+
62
+ def collect(entity_class, acc)
63
+ return unless entity_class.respond_to?(:root_exposures)
64
+ return if acc.include?(entity_class)
65
+
66
+ acc << entity_class
67
+ entity_class.root_exposures.each do |exposure|
68
+ next unless represent_exposure?(exposure)
69
+
70
+ nested = resolve_class(exposure.using_class_name)
71
+ collect(nested, acc) if nested
72
+ end
73
+ end
74
+
75
+ def build_name_map(classes)
76
+ map = {}
77
+ classes.group_by { |c| short_name(c) }.each_value do |group|
78
+ if group.size == 1
79
+ map[group.first.name] = short_name(group.first)
80
+ else
81
+ disambiguate(group).each { |full, name| map[full] = name }
82
+ end
83
+ end
84
+ map
85
+ end
86
+
87
+ # Pick the shortest trailing namespace suffix that makes every class in the
88
+ # colliding group unique. Deterministic — depends only on class names.
89
+ def disambiguate(group)
90
+ parts = group.to_h { |c| [c.name, c.name.to_s.split("::")] }
91
+ depth = 2
92
+ loop do
93
+ names = parts.transform_values { |segs| segs.last(depth).join(@separator) }
94
+ break names if names.values.uniq.size == names.size
95
+ break names if parts.values.all? { |segs| segs.size <= depth }
96
+
97
+ depth += 1
98
+ end
99
+ end
100
+
101
+ def short_name(entity_class)
102
+ entity_class.name.to_s.split("::").last
103
+ end
104
+
105
+ def schema_name(entity_class)
106
+ @name_map[entity_class.name] || short_name(entity_class)
107
+ end
108
+
109
+ # --- schema building -------------------------------------------------
110
+
41
111
  def build_schema(entity_class)
42
112
  properties = {}
43
113
  required = []
@@ -46,12 +116,14 @@ module GrapeOpenapi3
46
116
  key = exposure.key(nil).to_s
47
117
  doc = exposure.documentation || {}
48
118
 
49
- prop = build_property(exposure, doc)
119
+ prop = build_property(exposure, doc)
120
+ ref_like = prop.key?("$ref")
50
121
 
51
122
  # OpenAPI 3.0: $ref cannot have sibling properties — wrap in allOf
52
- if prop.key?("$ref") && (!doc[:desc].nil? || doc[:nullable])
53
- prop = { "allOf" => [prop] }
54
- end
123
+ prop = { "allOf" => [prop] } if ref_like && (!doc[:desc].nil? || doc[:nullable])
124
+
125
+ # enum/format/example/min/max only apply to value schemas, never to $refs
126
+ apply_keywords(prop, doc) unless ref_like
55
127
 
56
128
  prop["description"] = doc[:desc].to_s unless doc[:desc].nil?
57
129
  prop["nullable"] = true if doc[:nullable]
@@ -65,6 +137,38 @@ module GrapeOpenapi3
65
137
  schema
66
138
  end
67
139
 
140
+ # Attach OpenAPI value keywords from the entity's documentation hash.
141
+ # enum (or values), format, minimum, maximum → the scalar schema
142
+ # (the array's items when it's an array of scalars, otherwise the prop)
143
+ # default, example → the property itself
144
+ def apply_keywords(prop, doc)
145
+ scalar = scalar_target(prop)
146
+ enum = doc[:enum] || doc[:values]
147
+
148
+ if scalar
149
+ scalar["enum"] = Array(enum) if enum
150
+ scalar["format"] = doc[:format].to_s if doc[:format]
151
+ scalar["minimum"] = doc[:minimum] unless doc[:minimum].nil?
152
+ scalar["maximum"] = doc[:maximum] unless doc[:maximum].nil?
153
+ end
154
+
155
+ prop["default"] = doc[:default] unless doc[:default].nil?
156
+ prop["example"] = doc[:example] unless doc[:example].nil?
157
+ end
158
+
159
+ # The schema that scalar keywords belong to, or nil when there is none
160
+ # (e.g. an array of $refs). For an array of scalars it is the `items` schema.
161
+ def scalar_target(prop)
162
+ return nil if prop.key?("$ref") || prop.key?("allOf")
163
+
164
+ if prop["type"] == "array"
165
+ items = prop["items"]
166
+ items.is_a?(Hash) && !items.key?("$ref") ? items : nil
167
+ else
168
+ prop
169
+ end
170
+ end
171
+
68
172
  def build_property(exposure, doc)
69
173
  if represent_exposure?(exposure)
70
174
  nested_class = resolve_class(exposure.using_class_name)
@@ -87,10 +191,6 @@ module GrapeOpenapi3
87
191
  rescue NameError
88
192
  nil
89
193
  end
90
-
91
- def schema_name(entity_class)
92
- entity_class.name.to_s.split("::").last
93
- end
94
194
  end
95
195
  end
96
196
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOpenapi3
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
@@ -44,13 +44,15 @@ module GrapeOpenapi3
44
44
  # },
45
45
  # security: [{ "Bearer" => [] }]
46
46
  # )
47
- def generate(app, info:, servers: [], security_schemes: {}, security: [], tags: [])
47
+ def generate(app, info:, servers: [], security_schemes: {}, security: [], tags: [],
48
+ schema_name_separator: "_")
48
49
  config = Config.new(
49
- info: info,
50
- servers: servers,
51
- security_schemes: security_schemes,
52
- security: security,
53
- tags: tags,
50
+ info: info,
51
+ servers: servers,
52
+ security_schemes: security_schemes,
53
+ security: security,
54
+ tags: tags,
55
+ schema_name_separator: schema_name_separator,
54
56
  )
55
57
  Document.build(app, config)
56
58
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape_openapi3
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - rodrigonbarreto
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-15 00:00:00.000000000 Z
11
+ date: 2026-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape