grape-oas 1.4.0 → 1.5.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +27 -10
  4. data/UPGRADING.md +104 -0
  5. data/grape-oas.gemspec +2 -1
  6. data/lib/grape_oas/api_model/api.rb +2 -1
  7. data/lib/grape_oas/api_model/schema.rb +1 -1
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +9 -1
  10. data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
  11. data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
  12. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +4 -2
  13. data/lib/grape_oas/api_model_builders/operation.rb +3 -4
  14. data/lib/grape_oas/api_model_builders/path.rb +33 -6
  15. data/lib/grape_oas/api_model_builders/request.rb +14 -16
  16. data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +1 -1
  19. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +3 -9
  20. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  21. data/lib/grape_oas/constants.rb +16 -4
  22. data/lib/grape_oas/documentation_extension.rb +7 -9
  23. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  24. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  25. data/lib/grape_oas/exporter/oas2/parameter.rb +73 -13
  26. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  27. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  28. data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
  29. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  30. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  31. data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
  32. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  33. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  34. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  35. data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
  36. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  37. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  38. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  39. data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
  40. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  41. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  42. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  43. data/lib/grape_oas/range_utils.rb +25 -2
  44. data/lib/grape_oas/type_resolvers/array_resolver.rb +53 -19
  45. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  46. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  47. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  48. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  49. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  50. data/lib/grape_oas/version.rb +1 -1
  51. data/lib/grape_oas.rb +37 -7
  52. metadata +5 -4
  53. data/CONTRIBUTING.md +0 -87
  54. data/RELEASING.md +0 -109
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 585f37dd85b6630ef0d9599e3c1a02172cda35ff81ba6b9782e500d6bdaf8115
4
- data.tar.gz: b7e2cec6f5c24f7927e7290c87dffd2a488fe77768c039d9bc711cc79e868373
3
+ metadata.gz: e76945cff9198556752b881ff62da8c288a18e3f6447a36b7668d516ed575f4f
4
+ data.tar.gz: 8f36610d66d2d5935004972fdc063e74316b2a576be030ce7d8a0a903fa43f79
5
5
  SHA512:
6
- metadata.gz: 74106be01a4e9d0a9625d4b328df7b319de5dfe1da3d74d37f605d9328eb7fe200dccc761a865a66963797ad209871f71402f0c601e7f9d27ef9fbfe23408c69
7
- data.tar.gz: fbc867bb5e8167fe096a6024285986ac410385b3bc07cdca2b1c62c73bb0cbd7a4e9e1f0dff271212637dac1ac43842e8417d36b93bc39984d396cc8cd9329f3
6
+ metadata.gz: df261c7d71e8c228c096484cda70c821e4cdd58f1aad04b31bf402513dd414eff69512a38d9c6ef131de418fd19403c0ddadfc4ae7fe0bd8e12078ffc66e285a
7
+ data.tar.gz: 233f3e233f053f12f7b9fb2eb4635c8969233b4d8848b61048bf273512cffed54ce9499f473b90b56875c0d429f4d12a46f74148689fa122002a7e049fc1f383
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-09-07
9
+
10
+ ### Added
11
+
12
+ - [#123](https://github.com/numbata/grape-oas/pull/123): Add opt-in OAS2 composition extensions and include missing referenced alternatives in definitions - [@numbata](https://github.com/numbata).
13
+ - [#96](https://github.com/numbata/grape-oas/pull/96): Pin rubocop versions and add performance/packaging/rake plugins - [@numbata](https://github.com/numbata).
14
+ - [#92](https://github.com/numbata/grape-oas/pull/92): Add cross-tool AI-agent contributor guidance and tighten gem file packaging - [@numbata](https://github.com/numbata).
15
+ - [#88](https://github.com/numbata/grape-oas/pull/88): Add configurable `GrapeOAS.schema_ref_name` callable that controls how a schema's canonical class name is mangled into its OAS component/definition ref name. Default behavior (`name.gsub("::", "_")`) is byte-identical to prior output; applies uniformly across OAS 2.0 and OAS 3 ref emission paths - [@abeljim8am](https://github.com/abeljim8am).
16
+
17
+ ### Fixed
18
+
19
+ - [#128](https://github.com/numbata/grape-oas/pull/128): Honor string keys in entity-root documentation - [@numbata](https://github.com/numbata).
20
+ - [#127](https://github.com/numbata/grape-oas/pull/127): Drop stale OAS2 `x-anyOf`/`x-oneOf` compatibility extensions when native OAS3 composition is present - [@numbata](https://github.com/numbata).
21
+ - [#126](https://github.com/numbata/grape-oas/pull/126): Prefer bare `$ref` in nullable OAS3 null-union alternatives (drop redundant single-element `allOf`) - [@numbata](https://github.com/numbata).
22
+ - [#125](https://github.com/numbata/grape-oas/pull/125): Preserve nullability on root OAS3 request and response `$ref` schemas - [@numbata](https://github.com/numbata).
23
+ - [#122](https://github.com/numbata/grape-oas/pull/122): Treat params declared inside Grape `given` blocks as optional — no longer emitted as unconditionally required - [@numbata](https://github.com/numbata).
24
+ - [#121](https://github.com/numbata/grape-oas/pull/121): Preserve nullable references, array items, and polymorphic discriminators in OAS 3.0/3.1 - [@numbata](https://github.com/numbata).
25
+ - [#120](https://github.com/numbata/grape-oas/pull/120): Honor explicit route `consumes:`/`produces:` independently so form request bodies are documented with the declared media type - [@numbata](https://github.com/numbata).
26
+ - [#119](https://github.com/numbata/grape-oas/pull/119): Stop inferring `format: int32` for generic `Integer`/`"integer"` declarations; explicit int32/int64 widths are still preserved - [@numbata](https://github.com/numbata).
27
+ - [#113](https://github.com/numbata/grape-oas/pull/113): Use OAS 3.1 schema rendering for inline parameters, request bodies, and responses - [@numbata](https://github.com/numbata).
28
+ - [#112](https://github.com/numbata/grape-oas/pull/112): Exclude format extensions from path parameter discovery - [@numbata](https://github.com/numbata).
29
+ - [#100](https://github.com/numbata/grape-oas/pull/100): Preserve nil enum members when the selected nullable strategy can express them - [@numbata](https://github.com/numbata).
30
+ - [#104](https://github.com/numbata/grape-oas/pull/104): Preserve array-valued schema examples and Boolean false examples from parameter/entity documentation in OAS 3.0/3.1; omit incompatible scalar examples - [@olivier-thatch](https://github.com/olivier-thatch).
31
+ - [#109](https://github.com/numbata/grape-oas/pull/109): Fix Grape 3.3 variant collections, Grape 4.0 route metadata, and OAS 2.0 query array items - [@numbata](https://github.com/numbata).
32
+ - [#97](https://github.com/numbata/grape-oas/pull/97): Default OAS 2.0 nullable strategy to EXTENSION so nullable fields emit `x-nullable: true` without explicit opt-in - [@numbata](https://github.com/numbata).
33
+ - [#79](https://github.com/numbata/grape-oas/pull/79): Fix docs: `nickname` is supported in grape-oas - [@bogdan](https://github.com/bogdan).
34
+ - [#78](https://github.com/numbata/grape-oas/pull/78): Fix: use empty schema for undocumented responses instead of `{ type: string }` - [@bogdan](https://github.com/bogdan).
35
+ - [#74](https://github.com/numbata/grape-oas/pull/74): Fix BigDecimal range bounds serializing as JSON strings - [@olivier-thatch](https://github.com/olivier-thatch).
36
+ - [#76](https://github.com/numbata/grape-oas/pull/76): Emit OAS-version-correct schema for file types - [@olivier-thatch](https://github.com/olivier-thatch).
37
+ - [#82](https://github.com/numbata/grape-oas/pull/82): Fix: respect `documentation: { hidden: true }` on entity exposures - [@bogdan](https://github.com/bogdan).
38
+ - [#80](https://github.com/numbata/grape-oas/pull/80): Fix: hide documentation routes from generated spec by default - [@bogdan](https://github.com/bogdan).
39
+ - [#98](https://github.com/numbata/grape-oas/pull/98): Honor `documentation: { x: { nullable: true } }` on entity exposures - [@olivier-thatch](https://github.com/olivier-thatch).
40
+ - [#85](https://github.com/numbata/grape-oas/pull/85): Honor `is_array: true` on the plain-entity response branch - [@abeljim8am](https://github.com/abeljim8am).
41
+ - [#87](https://github.com/numbata/grape-oas/pull/87): Fix `SchemaIndexer#index_schema` to recurse into `schema.items` so entities reachable only through an array wrapper (e.g. a property declared as `Array<OtherEntity>`) are included in the indexed schemas set - [@abeljim8am](https://github.com/abeljim8am).
42
+ - [#102](https://github.com/numbata/grape-oas/pull/102): Fix: nullable entity ref must not mutate shared cached schema - [@bogdan](https://github.com/bogdan).
43
+ - [#105](https://github.com/numbata/grape-oas/pull/105): Place array-valued `example` on the array schema instead of `items` for entity `is_array:` exposures without mutating shared entity schemas - [@olivier-thatch](https://github.com/olivier-thatch).
44
+ - [#86](https://github.com/numbata/grape-oas/pull/86): Omit response content for bodyless HTTP statuses (`1xx`, `204`, `205`, `304`), including declared entities and examples, so generated OpenAPI responses remain valid - [@abeljim8am](https://github.com/abeljim8am).
45
+ - [#84](https://github.com/numbata/grape-oas/pull/84): Substitute the concrete path version into route templates, including prefixed and mounted routes, for `version "...", using: :path` while preserving user-defined version parameters with other versioning strategies - [@abeljim8am](https://github.com/abeljim8am).
46
+
47
+ ### Changed
48
+
49
+ - [#95](https://github.com/numbata/grape-oas/pull/95): Entity exposures now consult `GrapeOAS.type_resolvers` - [@numbata](https://github.com/numbata).
50
+
8
51
  ## [1.4.0] - 2026-04-23
9
52
 
10
53
  ### Fixed
data/README.md CHANGED
@@ -36,15 +36,17 @@ Grape::OAS is built around a **DTO (Data Transfer Object) architecture** that se
36
36
  ## Features
37
37
 
38
38
  - **Multi-version support**: Generate OAS 2.0, 3.0, or 3.1 from the same API
39
- - **Entity integration**: Works with [grape-entity](https://github.com/ruby-grape/grape-entity) and [dry-struct](https://dry-rb.org/gems/dry-struct/)
39
+ - **Entity and contract integration**: Works with [grape-entity](https://github.com/ruby-grape/grape-entity), [dry-validation](https://dry-rb.org/gems/dry-validation/), and [dry-schema](https://dry-rb.org/gems/dry-schema/)
40
40
  - **Automatic type inference**: Derives OpenAPI types from Grape parameter definitions
41
41
  - **Flexible output**: Mount as an endpoint or generate programmatically
42
42
 
43
43
  ## Compatibility
44
44
 
45
- | grape-oas | grape | grape-entity | dry-struct | Ruby |
46
- |-----------|-------|--------------|------------|------|
47
- | 0.1.x | >= 3.0 | >= 0.7 | >= 1.0 | >= 3.2 |
45
+ | grape-oas | grape | Ruby |
46
+ |-----------|-------|------|
47
+ | 1.5.x | >= 3.0 | >= 3.2 |
48
+
49
+ Entity and contract integrations are optional; install the gems your API uses.
48
50
 
49
51
  ## Installation
50
52
 
@@ -52,11 +54,12 @@ Grape::OAS is built around a **DTO (Data Transfer Object) architecture** that se
52
54
  gem 'grape-oas'
53
55
  ```
54
56
 
55
- For entity support:
57
+ For entity or contract support, add the relevant gems:
56
58
 
57
59
  ```ruby
58
- gem 'grape-entity' # For grape-entity support
59
- gem 'dry-struct' # For dry-struct contract support
60
+ gem 'grape-entity' # Grape::Entity response schemas
61
+ gem 'dry-validation' # Dry::Validation::Contract request schemas
62
+ gem 'dry-schema' # Standalone Dry::Schema request schemas
60
63
  ```
61
64
 
62
65
  ## Quick Start
@@ -82,10 +85,15 @@ end
82
85
  ```
83
86
 
84
87
  Documentation available at:
88
+
85
89
  - `/swagger_doc` - OpenAPI 3.0 (default)
86
90
  - `/swagger_doc?oas=2` - OpenAPI 2.0
87
91
  - `/swagger_doc?oas=3.1` - OpenAPI 3.1
88
92
 
93
+ Documentation routes are accessible over HTTP but excluded from the generated
94
+ specification by default. Set `hide_documentation_path: false` to include them.
95
+ To disable the endpoints, omit or conditionally call `add_oas_documentation`.
96
+
89
97
  ### Manual Generation
90
98
 
91
99
  ```ruby
@@ -96,17 +104,26 @@ puts JSON.pretty_generate(spec)
96
104
 
97
105
  ### Rake Tasks
98
106
 
107
+ Load your API before registering the tasks:
108
+
99
109
  ```ruby
100
110
  # In Rakefile
101
- require 'grape_oas/tasks'
111
+ require 'grape_oas'
112
+ require 'grape_oas/rake/oas_tasks'
113
+ require_relative 'app/api' # Defines MyAPI; adjust to your application's path
114
+
115
+ GrapeOAS::Rake::OasTasks.new(MyAPI)
102
116
  ```
103
117
 
104
118
  ```bash
105
- rake grape_oas:generate[MyAPI,oas31,spec/openapi.json]
119
+ mkdir -p spec
120
+ bundle exec rake oas:generate version=oas31 output=spec/openapi.json
106
121
  ```
107
122
 
108
123
  ## Documentation
109
124
 
125
+ Upgrading an existing application? Read [UPGRADING.md](UPGRADING.md).
126
+
110
127
  | Document | Description |
111
128
  |----------|-------------|
112
129
  | [Configuration](docs/CONFIGURATION.md) | All configuration options |
@@ -202,7 +219,7 @@ schema = GrapeOAS.generate(app: API, schema_type: :custom)
202
219
  git clone https://github.com/numbata/grape-oas.git
203
220
  cd grape-oas
204
221
  bin/setup
205
- bundle exec rake test
222
+ bundle exec rake
206
223
  ```
207
224
 
208
225
  ## Contributing
data/UPGRADING.md ADDED
@@ -0,0 +1,104 @@
1
+ # Upgrading grape-oas
2
+
3
+ ### Upgrading to >= 1.5.0
4
+
5
+ When upgrading from 1.4.0, regenerate your OpenAPI documents and review the diff before regenerating clients.
6
+ This release corrects several schema shapes and changes how documentation routes
7
+ are mounted. See [CHANGELOG.md](CHANGELOG.md) for the full list of changes.
8
+
9
+ #### Documentation routes are mounted even when hidden
10
+
11
+ `hide_documentation_path: true` previously prevented documentation routes from
12
+ being mounted. It now mounts them and hides them from the generated specification.
13
+ It is also the new default.
14
+
15
+ If you used this option to disable documentation in an environment, conditionally
16
+ omit the DSL call instead:
17
+
18
+ ```ruby
19
+ class API < Grape::API
20
+ # Declare your API routes here.
21
+
22
+ add_oas_documentation if ENV["ENABLE_API_DOCS"] == "true"
23
+ end
24
+ ```
25
+
26
+ To include the documentation routes in the generated specification, use
27
+ `add_oas_documentation(hide_documentation_path: false)`. Hiding a route from the
28
+ specification does not restrict HTTP access to it.
29
+
30
+ #### Generic integers no longer imply a 32-bit format
31
+
32
+ Generic `Integer` and `"integer"` declarations now emit `type: integer` without
33
+ the previously inferred `format: int32`. This can change the integer type selected
34
+ by a client generator. Declare a width explicitly when your API requires it:
35
+
36
+ ```ruby
37
+ params do
38
+ requires :count, type: Integer, documentation: { format: "int32" }
39
+ end
40
+ ```
41
+
42
+ For entity exposures, use
43
+ `documentation: { type: "integer", format: "int64" }` (or `"int32"`).
44
+
45
+ #### Entity exposures use the type resolver registry
46
+
47
+ Entity exposures now consult `GrapeOAS.type_resolvers`. Existing custom resolvers
48
+ can therefore affect response schemas as well as request parameters. Review their
49
+ `handles?` predicates and compare generated entity schemas after upgrading.
50
+
51
+ The registry also has migration-relevant changes:
52
+
53
+ - Replace registry calls to `handles?(type)` with
54
+ `registered_resolver_for?(type)`; `handles?` now emits a deprecation warning.
55
+ Custom resolver classes still implement their own `handles?` method.
56
+ - `build_schema(type)` now returns a fallback string schema when no registered
57
+ resolver supplies a schema, including after `clear`. Do not use a `nil` result
58
+ to detect unsupported types. `registered_resolver_for?` checks whether a
59
+ registered resolver claims the type; it does not guarantee that the resolver
60
+ will return a schema.
61
+ - A resolver returning `nil` from `build_schema` now lets the registry try later
62
+ resolvers before using the fallback.
63
+ - `find` is now private. Use `registered_resolver_for?` for a membership check or
64
+ `build_schema` to resolve a type. To inspect the first matching resolver, use
65
+ `GrapeOAS.type_resolvers.to_a.find { |resolver| resolver.handles?(type) }`.
66
+
67
+ #### Explicit media types affect OAS2 form generation
68
+
69
+ Route `consumes:` and `produces:` declarations now override inferred media types
70
+ independently. Check existing declarations for stale values.
71
+
72
+ For OAS2 operations consuming only `application/x-www-form-urlencoded` or
73
+ `multipart/form-data`, request properties now become `formData` parameters.
74
+ Complex form fields (objects, compositions, or referenced array items) raise
75
+ `ArgumentError` during generation. Use OAS3 to describe those requests, or simplify
76
+ the form schema if that accurately reflects your API. Primitive fields, files,
77
+ and arrays of primitives remain supported.
78
+
79
+ #### Generated schemas reflect corrected types and requiredness
80
+
81
+ Review schema snapshots and generated clients for these corrections:
82
+
83
+ - **Nullability:** OAS2 emits `x-nullable: true` by default for nullable schemas.
84
+ OAS3.1 inline parameters, request bodies, and responses now use its JSON Schema
85
+ null representation. Nullable OAS3 references and compositions retain
86
+ nullability, and null-union alternatives may now contain a bare `$ref` instead
87
+ of a single-element `allOf`. Update tooling that assumes the old wrapper shape.
88
+ - **Conditional parameters:** fields declared inside Grape `given` blocks are
89
+ optional in the generated schema unless also required unconditionally.
90
+ Conditional fields alone no longer make the request body required. Grape still
91
+ enforces conditions at runtime; the generated schema does not encode them.
92
+ - **Responses:** undocumented response schemas are now empty (`{}`) instead of
93
+ `type: string`. Declare a response entity if consumers need a concrete type.
94
+ Responses with status `1xx`, `204`, `205`, or `304` omit body schemas/content,
95
+ including declared entities and examples. Plain-entity responses with
96
+ `is_array: true` now correctly emit arrays.
97
+ - **Hidden entity fields:** exposures with `documentation: { hidden: true }` are
98
+ omitted. Remove that flag if the field should remain documented.
99
+ - **Versioned paths:** path-based Grape versions now appear as concrete path
100
+ segments instead of unresolved version parameters. Regenerate clients that
101
+ previously accepted the version as an argument.
102
+
103
+ New `schema_ref_name` and `oas2_composition_extensions` options are opt-in and
104
+ require no configuration changes for existing applications.
data/grape-oas.gemspec CHANGED
@@ -24,7 +24,8 @@ Gem::Specification.new do |spec|
24
24
  "rubygems_mfa_required" => "true"
25
25
  }
26
26
 
27
- spec.files = Dir["lib/**/*", "*.md", "LICENSE.txt", "grape-oas.gemspec"]
27
+ spec.files = Dir["lib/**/*"].reject { |path| File.directory?(path) } +
28
+ %w[README.md CHANGELOG.md UPGRADING.md LICENSE.txt grape-oas.gemspec]
28
29
 
29
30
  spec.add_dependency "grape", ">= 3.0"
30
31
  spec.add_dependency "zeitwerk"
@@ -11,7 +11,7 @@ module GrapeOAS
11
11
  class API < Node
12
12
  attr_accessor :title, :version, :paths, :servers, :tag_defs, :components,
13
13
  :host, :base_path, :schemes, :security_definitions, :security,
14
- :registered_schemas, :suppress_default_error_response, :nullable_strategy
14
+ :registered_schemas, :suppress_default_error_response, :nullable_strategy, :oas2_composition_extensions
15
15
 
16
16
  def initialize(title:, version:)
17
17
  super()
@@ -29,6 +29,7 @@ module GrapeOAS
29
29
  @registered_schemas = []
30
30
  @suppress_default_error_response = false
31
31
  @nullable_strategy = nil
32
+ @oas2_composition_extensions = false
32
33
  end
33
34
 
34
35
  def add_path(path)
@@ -14,7 +14,7 @@ module GrapeOAS
14
14
  examples default extensions
15
15
  min_length max_length pattern
16
16
  minimum maximum exclusive_minimum exclusive_maximum
17
- min_items max_items
17
+ min_items max_items unique_items
18
18
  discriminator all_of one_of any_of
19
19
  ].freeze
20
20
 
@@ -20,6 +20,7 @@ module GrapeOAS
20
20
  @api.registered_schemas = build_registered_schemas(options[:models])
21
21
  @api.suppress_default_error_response = options[:suppress_default_error_response] || false
22
22
  @api.nullable_strategy = options[:nullable_strategy]
23
+ @api.oas2_composition_extensions = options[:oas2_composition_extensions] == true
23
24
 
24
25
  @namespace_filter = options[:namespace]
25
26
  @apis = []
@@ -53,12 +54,12 @@ module GrapeOAS
53
54
  def build_registered_schemas(models)
54
55
  return [] unless models
55
56
 
56
- Array(models).map do |model|
57
+ Array(models).filter_map do |model|
57
58
  model = model.constantize if model.is_a?(String)
58
59
  GrapeOAS.introspectors.build_schema(model, stack: [], registry: {})
59
60
  rescue StandardError
60
61
  nil
61
- end.compact
62
+ end
62
63
  end
63
64
  end
64
65
  end
@@ -8,6 +8,14 @@ module GrapeOAS
8
8
  module ContentTypeResolver
9
9
  private
10
10
 
11
+ def explicit_media_types(key)
12
+ declared = route.options[key] || route.options.dig(:documentation, key)
13
+ mimes = Array(declared).filter_map do |mime|
14
+ mime.to_s.include?("/") ? mime.to_s : Grape::ContentTypes.content_types_for(nil)[mime.to_s.to_sym]
15
+ end
16
+ mimes.empty? ? nil : mimes.uniq
17
+ end
18
+
11
19
  def resolve_content_types
12
20
  default_format = route_default_format_from_route || default_format_from_app_or_api
13
21
  content_types = route_content_types_from_route
@@ -24,7 +32,7 @@ module GrapeOAS
24
32
 
25
33
  mimes << mime_for_format(default_format) if mimes.empty? && default_format
26
34
 
27
- mimes = mimes.map { |m| normalize_mime(m) }.compact
35
+ mimes = mimes.filter_map { |m| normalize_mime(m) }
28
36
  mimes.empty? ? [Constants::MimeTypes::JSON] : mimes.uniq
29
37
  end
30
38
 
@@ -24,6 +24,19 @@ module GrapeOAS
24
24
  OasUtilities.extract_extensions(hash)
25
25
  end
26
26
 
27
+ # Extracts nullable flag from a documentation hash.
28
+ #
29
+ # @param doc [Hash] the documentation hash
30
+ # @return [Boolean] true if nullable
31
+ def self.extract_nullable(doc)
32
+ doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
33
+ end
34
+
35
+ # Instance method version
36
+ def extract_nullable(doc)
37
+ OasUtilities.extract_nullable(doc)
38
+ end
39
+
27
40
  # Converts a CamelCase string to snake_case.
28
41
  #
29
42
  # @param str [String] the string to convert
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ module Concerns
6
+ module RouteValidations
7
+ private
8
+
9
+ # Grape 3.x snapshots validators on `inheritable_setting.route[:saved_validations]`.
10
+ # Grape 4.0 renamed that to `#route_validations` / `route[:validations]` (grape#2811).
11
+ def grape_route_validations(setting)
12
+ if setting.respond_to?(:route_validations)
13
+ validations = setting.route_validations
14
+ return validations if validations.is_a?(Array)
15
+ end
16
+ return unless setting.respond_to?(:route)
17
+
18
+ route_store = setting.route
19
+ return unless route_store.is_a?(Hash)
20
+
21
+ route_store[:saved_validations] || route_store[:validations]
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -14,6 +14,7 @@ module GrapeOAS
14
14
  module TypeResolver
15
15
  TYPED_ARRAY_PATTERN = Constants::TypePatterns::TYPED_ARRAY
16
16
  MULTI_TYPE_PATTERN = Constants::TypePatterns::MULTI_TYPE
17
+ VARIANT_COLLECTION_PATTERN = Constants::TypePatterns::VARIANT_COLLECTION
17
18
 
18
19
  # Resolves a Ruby class or type name to its OpenAPI schema type string.
19
20
  # Handles both Ruby classes (Integer, Float) and string type names ("integer", "float").
@@ -39,8 +40,9 @@ module GrapeOAS
39
40
 
40
41
  type_str = type.to_s
41
42
 
42
- # Handle Grape's typed array notation like "[String]"
43
- return Constants::SchemaTypes::ARRAY if type_str.match?(TYPED_ARRAY_PATTERN)
43
+ # Handle Grape's typed array notation like "[String]" / "Array[String]"
44
+ # and Grape 3.3+ variant collections like "Array[Integer, String]".
45
+ return Constants::SchemaTypes::ARRAY if type_str.match?(TYPED_ARRAY_PATTERN) || type_str.match?(VARIANT_COLLECTION_PATTERN)
44
46
 
45
47
  # Handle string/symbol type names
46
48
  Constants.primitive_type(type_str) || Constants::SchemaTypes::STRING
@@ -49,8 +49,7 @@ module GrapeOAS
49
49
  slug = route
50
50
  .pattern
51
51
  .origin
52
- .gsub(/[^a-z0-9]+/i, "_")
53
- .gsub(/_+/, "_")
52
+ .gsub(/[^a-z0-9]+/i, "_").squeeze("_")
54
53
  .sub(/^_|_$/, "")
55
54
 
56
55
  "#{http_method}_#{slug}"
@@ -125,11 +124,11 @@ module GrapeOAS
125
124
  end
126
125
 
127
126
  def consumes
128
- resolve_content_types
127
+ explicit_media_types(:consumes) || resolve_content_types
129
128
  end
130
129
 
131
130
  def produces
132
- resolve_content_types
131
+ explicit_media_types(:produces) || resolve_content_types
133
132
  end
134
133
 
135
134
  def operation_extensions
@@ -10,6 +10,11 @@ module GrapeOAS
10
10
  PATH_PARAMETER_PATTERN = %r{(?<=/):(?<param>[^/]+)}
11
11
  private_constant :PATH_PARAMETER_PATTERN
12
12
 
13
+ # Matches Grape's wildcard segments: /?*param or /*param
14
+ # The optional ? before * is Grape's syntax for an optional leading slash
15
+ WILDCARD_PARAMETER_PATTERN = %r{\??\*(?<param>[^/()]+)}
16
+ private_constant :WILDCARD_PARAMETER_PATTERN
17
+
13
18
  NORMALIZED_PLACEHOLDER = /\{[^}]+\}/
14
19
  private_constant :NORMALIZED_PLACEHOLDER
15
20
 
@@ -28,7 +33,7 @@ module GrapeOAS
28
33
  @routes.each_with_object({}) do |route, api_routes|
29
34
  next if skip_route?(route)
30
35
 
31
- route_path = sanitize_path(route.path)
36
+ route_path = sanitize_path(route.path, route: route)
32
37
  normalized = normalize_template(route_path)
33
38
 
34
39
  canonical_info = canonical_paths[normalized]
@@ -79,7 +84,7 @@ module GrapeOAS
79
84
  def filtered_by_namespace?(route)
80
85
  return false unless namespace_filter
81
86
 
82
- route_path = sanitize_path(route.path)
87
+ route_path = sanitize_path(route.path, route: route)
83
88
  namespace_prefix = namespace_filter.start_with?("/") ? namespace_filter : "/#{namespace_filter}"
84
89
 
85
90
  # Match exact namespace or namespace followed by / or {
@@ -95,10 +100,32 @@ module GrapeOAS
95
100
  .build
96
101
  end
97
102
 
98
- def sanitize_path(path)
99
- path
100
- .gsub(EXTENSION_PATTERN, "") # Remove format extensions like (.json)
101
- .gsub(PATH_PARAMETER_PATTERN, "{\\k<param>}") # Replace named parameters with curly braces
103
+ def sanitize_path(path, route: nil)
104
+ sanitized = path.gsub(EXTENSION_PATTERN, "") # Remove format extensions like (.json)
105
+ .gsub(WILDCARD_PARAMETER_PATTERN, "{\\k<param>}") # Replace *param / /?*param with {param}
106
+ .gsub(PATH_PARAMETER_PATTERN, "{\\k<param>}") # Replace :param with {param}
107
+
108
+ concrete_version = concrete_path_version(route) if route
109
+ return sanitized unless concrete_version
110
+
111
+ # Path versioning owns {version}; Grape rejects duplicate capture names.
112
+ sanitized.sub("{version}", concrete_version)
113
+ end
114
+
115
+ def concrete_path_version(route)
116
+ setting = route.app.inheritable_setting
117
+ version_options = if setting.respond_to?(:version_options)
118
+ setting.version_options
119
+ else
120
+ setting.namespace_inheritable[:version_options]
121
+ end
122
+ return nil unless version_options&.to_h&.dig(:using) == :path
123
+
124
+ values = Array(route.version)
125
+ return nil unless values.length == 1
126
+
127
+ value = values.first.to_s
128
+ value.empty? ? nil : value
102
129
  end
103
130
 
104
131
  def normalize_template(path)
@@ -3,6 +3,7 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  class Request
6
+ include Concerns::RouteValidations
6
7
  include Concerns::TypeResolver
7
8
  include Concerns::OasUtilities
8
9
 
@@ -96,7 +97,9 @@ module GrapeOAS
96
97
 
97
98
  # Find contract from Grape's contract storage locations.
98
99
  # Contracts can be defined in several ways:
99
- # 1. Via `contract MyContract` DSL - stores in inheritable_setting.route[:saved_validations]
100
+ # 1. Via `contract MyContract` DSL - stored on the endpoint's inheritable
101
+ # settings (`route_validations` on Grape 4.0, `route[:saved_validations]`
102
+ # on Grape 3.x)
100
103
  # 2. Via `desc "...", contract: MyContract` - stores in route.options[:contract]
101
104
  # 3. Via `desc "...", schema: MySchema` - stores in route.options[:schema]
102
105
  # 4. Via route.settings[:contract] - used by mounted APIs or legacy configuration
@@ -116,22 +119,15 @@ module GrapeOAS
116
119
  end
117
120
 
118
121
  # Extract contract from Grape's native contract() DSL storage location.
119
- # When using `contract MyContract` in Grape DSL, the contract is stored in
120
- # route.app.inheritable_setting.route[:saved_validations] as validator options.
121
- # This is a point-in-time copy specific to this endpoint, ensuring each route
122
+ # When using `contract MyContract` in Grape DSL, the contract is stored as
123
+ # a point-in-time copy specific to this endpoint, ensuring each route
122
124
  # gets only its own contract even when multiple routes define different contracts.
123
125
  #
124
126
  # @return [Object, nil] The contract instance or nil if not found
125
127
  def extract_contract_from_grape_validations
126
128
  return unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
127
129
 
128
- setting = route.app.inheritable_setting
129
- return unless setting.respond_to?(:route)
130
-
131
- # Use route[:saved_validations] which contains only the validations
132
- # for this specific endpoint (point-in-time copy), not the shared
133
- # namespace_stackable[:validations] which contains all validators for the API class
134
- validations = setting.route[:saved_validations]
130
+ validations = grape_route_validations(route.app.inheritable_setting)
135
131
  return unless validations.is_a?(Array)
136
132
 
137
133
  # Find ContractScopeValidator which holds the Dry contract/schema.
@@ -147,10 +143,7 @@ module GrapeOAS
147
143
 
148
144
  return v.dig(:opts, :schema)
149
145
  when Grape::Validations::Validators::ContractScopeValidator
150
- # Grape 3.2 removed attr_reader :schema and freezes the validator,
151
- # so instance_variable_get is the only way to access the schema.
152
- # TODO: use v.schema once ruby-grape/grape#2657 restores the accessor.
153
- schema = v.instance_variable_get(:@schema)
146
+ schema = contract_schema_from(v)
154
147
  GrapeOAS.logger&.warn("ContractScopeValidator found but @schema is nil") if schema.nil?
155
148
  return schema
156
149
  end
@@ -159,6 +152,11 @@ module GrapeOAS
159
152
  nil
160
153
  end
161
154
 
155
+ def contract_schema_from(validator)
156
+ schema = validator.schema if validator.respond_to?(:schema)
157
+ schema || validator.instance_variable_get(:@schema)
158
+ end
159
+
162
160
  def build_contract_schema
163
161
  contract = find_contract
164
162
  return unless contract
@@ -265,7 +263,7 @@ module GrapeOAS
265
263
  end
266
264
 
267
265
  def path_param_names
268
- names = route.path.scan(RequestParams::ROUTE_PARAM_REGEX)
266
+ names = RequestParams.path_param_names(route.path)
269
267
  mapped_names = path_param_name_map ? path_param_name_map.values : []
270
268
  (names + mapped_names).map(&:to_s).uniq
271
269
  end