diff --git a/README.md b/README.md
index e83fd48a..5129b66f 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,9 @@
- [insert_after](#insert_after)
- [CORS](#cors)
- [Configure ](#configure-)
+ - [openapi_version: ](#openapi_version-)
+ - [json_schema_dialect: (OAS 3.1 only)](#json_schema_dialect-)
+ - [webhooks: (OAS 3.1 only)](#webhooks-)
- [host: ](#host-)
- [base_path: ](#base_path-)
- [mount_path: ](#mount_path-)
@@ -130,7 +133,9 @@ The following versions of grape, grape-entity and grape-swagger can currently be
## Swagger-Spec
-Grape-swagger generates documentation per [Swagger / OpenAPI Spec 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md).
+Grape-swagger generates documentation per [Swagger / OpenAPI Spec 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) by default.
+
+It also supports [OpenAPI 3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) and [OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md). See the [openapi_version configuration option](#openapi_version) for details.
@@ -260,6 +265,9 @@ end
## Configure
+* [openapi_version](#openapi_version)
+* [json_schema_dialect (OAS 3.1 only)](#json_schema_dialect)
+* [webhooks (OAS 3.1 only)](#webhooks)
* [host](#host)
* [base_path](#base_path)
* [mount_path](#mount_path)
@@ -294,6 +302,114 @@ add_swagger_documentation \
```
+#### openapi_version:
+Specifies which OpenAPI/Swagger version to generate. By default (`nil`), Swagger 2.0 is generated.
+
+Available options:
+- `nil` - Swagger 2.0 (default, for backward compatibility)
+- `'3.0'` - OpenAPI 3.0.3
+- `'3.1'` - OpenAPI 3.1.0
+
+```ruby
+# Swagger 2.0 (default)
+add_swagger_documentation
+
+# OpenAPI 3.0
+add_swagger_documentation \
+ openapi_version: '3.0'
+
+# OpenAPI 3.1
+add_swagger_documentation \
+ openapi_version: '3.1'
+```
+
+Key differences when using OpenAPI 3.x:
+- Body parameters are converted to `requestBody` with content type wrappers
+- Schema references use `#/components/schemas/` instead of `#/definitions/`
+- Parameters include a `schema` wrapper
+- `host`, `basePath`, and `schemes` are converted to a `servers` array
+- OpenAPI 3.1 uses `type: ["string", "null"]` instead of `nullable: true`
+
+
+#### json_schema_dialect:
+**OpenAPI 3.1 only.** Specifies the JSON Schema dialect used in the document. This option is ignored for OpenAPI 3.0 and Swagger 2.0.
+
+```ruby
+add_swagger_documentation \
+ openapi_version: '3.1',
+ json_schema_dialect: 'https://json-schema.org/draft/2020-12/schema'
+```
+
+This adds `jsonSchemaDialect` to the OpenAPI 3.1 output:
+```json
+{
+ "openapi": "3.1.0",
+ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
+ ...
+}
+```
+
+
+#### webhooks:
+**OpenAPI 3.1 only.** Defines webhook endpoints that your API can call. This option is ignored for OpenAPI 3.0 and Swagger 2.0.
+
+```ruby
+add_swagger_documentation \
+ openapi_version: '3.1',
+ webhooks: {
+ newPetAvailable: {
+ post: {
+ summary: 'New pet available',
+ description: 'A new pet has been added to the store',
+ operationId: 'newPetWebhook',
+ tags: ['pets'],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ petId: { type: 'integer', description: 'Pet ID' },
+ petName: { type: 'string', description: 'Pet name' }
+ },
+ required: %w[petId petName]
+ }
+ }
+ }
+ },
+ responses: {
+ '200': { description: 'Webhook received successfully' },
+ '400': { description: 'Invalid payload' }
+ }
+ }
+ }
+ }
+```
+
+You can also reference existing schemas:
+```ruby
+webhooks: {
+ petCreated: {
+ post: {
+ summary: 'Pet created',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: { '$ref': '#/components/schemas/Pet' }
+ }
+ }
+ },
+ responses: {
+ '200': { description: 'OK' }
+ }
+ }
+ }
+}
+```
+
+
#### host:
Sets explicit the `host`, default would be taken from `request`.
```ruby
diff --git a/docs/openapi_3.md b/docs/openapi_3.md
new file mode 100644
index 00000000..c2777251
--- /dev/null
+++ b/docs/openapi_3.md
@@ -0,0 +1,113 @@
+# OpenAPI 3.0/3.1 Support
+
+## Quick Start
+
+```ruby
+# Swagger 2.0 (default, unchanged)
+add_swagger_documentation
+
+# OpenAPI 3.0
+add_swagger_documentation(openapi_version: '3.0')
+
+# OpenAPI 3.1
+add_swagger_documentation(openapi_version: '3.1')
+```
+
+## Configuration Options
+
+```ruby
+add_swagger_documentation(
+ openapi_version: '3.1',
+ info: {
+ title: 'My API',
+ version: '1.0',
+ description: 'API description',
+ license: {
+ name: 'MIT',
+ url: 'https://opensource.org/licenses/MIT',
+ identifier: 'MIT' # OAS 3.1 only (SPDX)
+ }
+ },
+ security_definitions: {
+ bearer: { type: 'http', scheme: 'bearer' }
+ },
+ # OAS 3.1 specific
+ json_schema_dialect: 'https://json-schema.org/draft/2020-12/schema',
+ webhooks: {
+ newUser: {
+ post: {
+ summary: 'New user webhook',
+ requestBody: { ... },
+ responses: { '200' => { description: 'OK' } }
+ }
+ }
+ }
+)
+```
+
+## Key Differences from Swagger 2.0
+
+| Aspect | Swagger 2.0 | OpenAPI 3.x |
+|--------|-------------|-------------|
+| Version | `swagger: '2.0'` | `openapi: '3.0.3'` / `'3.1.0'` |
+| Body params | `in: body` parameter | `requestBody` object |
+| Form params | `in: formData` | `requestBody` with content-type |
+| File upload | `type: file` | `type: string, format: binary` |
+| Schema refs | `#/definitions/X` | `#/components/schemas/X` |
+| Host | `host`, `basePath`, `schemes` | `servers: [{url: "..."}]` |
+| Security defs | `securityDefinitions` | `components/securitySchemes` |
+| Param types | inline `type`, `format` | wrapped in `schema` |
+
+## Nullable Fields
+
+```ruby
+# Recommended syntax
+params do
+ optional :nickname, type: String, documentation: { nullable: true }
+end
+
+# Also supported (for backward compatibility with Swagger 2.0 code)
+params do
+ optional :nickname, type: String, documentation: { x: { nullable: true } }
+end
+```
+
+Both syntaxes produce version-appropriate output:
+
+| Syntax | Swagger 2.0 | OAS 3.0 | OAS 3.1 |
+|--------|-------------|---------|---------|
+| `documentation: { nullable: true }` | `x-nullable: true` | `nullable: true` | `type: ["string", "null"]` |
+| `documentation: { x: { nullable: true } }` | `x-nullable: true` | `nullable: true` | `type: ["string", "null"]` |
+
+This means existing Swagger 2.0 code using `x: { nullable: true }` works seamlessly when switching to OAS 3.x.
+
+## Architecture
+
+```
+Grape Routes
+ │
+ ▼
+┌─────────────────────────────┐
+│ Builder::Spec │
+│ (lib/grape-swagger/openapi)│
+└─────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────┐
+│ OpenAPI Model Layer │
+│ (Document, Schema, etc.) │
+└─────────────────────────────┘
+ │
+ ├──────────────┬──────────────┐
+ ▼ ▼ ▼
+┌──────────┐ ┌──────────┐ ┌──────────┐
+│ Swagger2 │ │ OAS30 │ │ OAS31 │
+│ Exporter │ │ Exporter │ │ Exporter │
+└──────────┘ └──────────┘ └──────────┘
+```
+
+## Backward Compatibility
+
+- **Default unchanged**: Without `openapi_version`, Swagger 2.0 is generated
+- **All existing options work**: Same configuration for both versions
+- **Model parsers unchanged**: grape-entity, representable, etc. work as before
diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb
index 9e929b25..9d27b1c4 100644
--- a/lib/grape-swagger.rb
+++ b/lib/grape-swagger.rb
@@ -13,8 +13,15 @@
require 'grape-swagger/request_param_parser_registry'
require 'grape-swagger/token_owner_resolver'
+# OpenAPI 3.x support
+require 'grape-swagger/openapi'
+require 'grape-swagger/openapi/builder'
+require 'grape-swagger/exporter'
+
module GrapeSwagger
class << self
+ attr_writer :schema_name_generator
+
def model_parsers
@model_parsers ||= GrapeSwagger::ModelParsers.new
end
@@ -22,6 +29,25 @@ def model_parsers
def request_param_parsers
@request_param_parsers ||= GrapeSwagger::RequestParamParserRegistry.new
end
+
+ def schema_name_generator
+ @schema_name_generator ||= default_schema_name_generator
+ end
+
+ private
+
+ def default_schema_name_generator
+ lambda { |model|
+ model_string = model.to_s
+ if model_string.end_with?('::Entity', '::Entities')
+ model_string.split('::')[0..-2].join('_')
+ elsif model_string.start_with?('Entity::', 'Entities::', 'Representable::')
+ model_string.split('::')[1..-1].join('_')
+ else
+ model_string.split('::').join('_')
+ end
+ }
+ end
end
autoload :Rake, 'grape-swagger/rake/oapi_tasks'
diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb
index 9e22499d..ec023d8c 100644
--- a/lib/grape-swagger/doc_methods.rb
+++ b/lib/grape-swagger/doc_methods.rb
@@ -13,6 +13,8 @@
require 'grape-swagger/doc_methods/move_params'
require 'grape-swagger/doc_methods/build_model_definition'
require 'grape-swagger/doc_methods/version'
+require 'grape-swagger/doc_methods/webhooks'
+require 'grape-swagger/doc_methods/output_builder'
module GrapeSwagger
module DocMethods
@@ -37,38 +39,18 @@ module DocMethods
specific_api_documentation: { desc: 'Swagger compatible API description for specific API' },
endpoint_auth_wrapper: nil,
swagger_endpoint_guard: nil,
- token_owner: nil
+ token_owner: nil,
+ # OpenAPI version: nil (Swagger 2.0), '3.0', or '3.1'
+ openapi_version: nil,
+ # OpenAPI 3.1 specific options
+ json_schema_dialect: nil,
+ webhooks: nil
}.freeze
FORMATTER_METHOD = %i[format default_format default_error_formatter].freeze
def self.output_path_definitions(combi_routes, endpoint, target_class, options)
- output = endpoint.swagger_object(
- target_class,
- endpoint.request,
- options
- )
-
- paths, definitions = endpoint.path_and_definition_objects(combi_routes, options)
- tags = tags_from(paths, options)
-
- output[:tags] = tags unless tags.empty? || paths.blank?
- output[:paths] = paths unless paths.blank?
- output[:definitions] = definitions unless definitions.blank?
-
- output
- end
-
- def self.tags_from(paths, options)
- tags = GrapeSwagger::DocMethods::TagNameDescription.build(paths)
-
- if options[:tags]
- names = options[:tags].map { |t| t[:name] }
- tags.reject! { |t| names.include?(t[:name]) }
- tags += options[:tags]
- end
-
- tags
+ OutputBuilder.build(combi_routes, endpoint, target_class, options)
end
def hide_documentation_path
diff --git a/lib/grape-swagger/doc_methods/build_model_definition.rb b/lib/grape-swagger/doc_methods/build_model_definition.rb
index 864d4471..fbb5a6cf 100644
--- a/lib/grape-swagger/doc_methods/build_model_definition.rb
+++ b/lib/grape-swagger/doc_methods/build_model_definition.rb
@@ -7,12 +7,20 @@ class << self
def build(_model, properties, required, other_def_properties = {})
definition = { type: 'object', properties: properties }.merge(other_def_properties)
- definition[:required] = required if required.is_a?(Array) && required.any?
+ if required.is_a?(Array) && required.any?
+ # Filter required to only include properties that actually exist
+ # (handles hidden properties that are removed from properties but left in required)
+ property_keys = properties.keys.map(&:to_s)
+ valid_required = required.select { |r| property_keys.include?(r.to_s) }
+ definition[:required] = valid_required if valid_required.any?
+ end
definition
end
def parse_params_from_model(parsed_response, model, model_name)
+ return parsed_response.to_h if parsed_response.is_a?(GrapeSwagger::OpenAPI::Schema)
+
if parsed_response.is_a?(Hash) && parsed_response.keys.first == :allOf
refs_or_models = parsed_response[:allOf]
parsed = parse_refs_and_models(refs_or_models, model)
diff --git a/lib/grape-swagger/doc_methods/data_type.rb b/lib/grape-swagger/doc_methods/data_type.rb
index 62f47f3c..9ec05c6d 100644
--- a/lib/grape-swagger/doc_methods/data_type.rb
+++ b/lib/grape-swagger/doc_methods/data_type.rb
@@ -48,15 +48,9 @@ def parse_multi_type(raw_data_type)
end
def parse_entity_name(model)
- if model.respond_to?(:entity_name)
- model.entity_name
- elsif model.to_s.end_with?('::Entity', '::Entities')
- model.to_s.split('::')[0..-2].join('_')
- elsif model.to_s.start_with?('Entity::', 'Entities::', 'Representable::')
- model.to_s.split('::')[1..-1].join('_')
- else
- model.to_s.split('::').join('_')
- end
+ return model.entity_name if model.respond_to?(:entity_name)
+
+ GrapeSwagger.schema_name_generator.call(model)
end
def request_primitive?(type)
diff --git a/lib/grape-swagger/doc_methods/move_params.rb b/lib/grape-swagger/doc_methods/move_params.rb
index c8417ce5..68fecae5 100644
--- a/lib/grape-swagger/doc_methods/move_params.rb
+++ b/lib/grape-swagger/doc_methods/move_params.rb
@@ -178,7 +178,7 @@ def parse_model(ref)
def property_keys
%i[type format description minimum maximum items enum default additional_properties additionalProperties
- example]
+ example nullable]
end
def deletable?(param)
diff --git a/lib/grape-swagger/doc_methods/output_builder.rb b/lib/grape-swagger/doc_methods/output_builder.rb
new file mode 100644
index 00000000..e8f2de43
--- /dev/null
+++ b/lib/grape-swagger/doc_methods/output_builder.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module DocMethods
+ # Builds output from routes in either Swagger 2.0 or OpenAPI 3.x format
+ module OutputBuilder
+ class << self
+ def build(combi_routes, endpoint, target_class, options)
+ if options[:openapi_version]
+ build_openapi3(combi_routes, endpoint, target_class, options)
+ else
+ build_swagger2(combi_routes, endpoint, target_class, options)
+ end
+ end
+
+ private
+
+ def build_swagger2(combi_routes, endpoint, target_class, options)
+ output = endpoint.swagger_object(
+ target_class,
+ endpoint.request,
+ options
+ )
+
+ paths, definitions = endpoint.path_and_definition_objects(combi_routes, options)
+ tags = tags_from(paths, options)
+
+ output[:tags] = tags unless tags.empty? || paths.blank?
+ output[:paths] = paths unless paths.blank?
+ output[:definitions] = definitions unless definitions.blank?
+
+ output
+ end
+
+ def build_openapi3(combi_routes, endpoint, target_class, options)
+ version = options[:openapi_version]
+
+ builder = GrapeSwagger::OpenAPI::Builder::Spec.new(
+ endpoint, target_class, endpoint.request, options
+ )
+ spec = builder.build(combi_routes)
+
+ apply_oas31_options(spec, options) if version.to_s.start_with?('3.1')
+
+ GrapeSwagger::Exporter.export(spec, version: version)
+ end
+
+ def apply_oas31_options(spec, options)
+ spec.json_schema_dialect = options[:json_schema_dialect] if options[:json_schema_dialect]
+ Webhooks.apply(spec, options[:webhooks]) if options[:webhooks]
+ end
+
+ def tags_from(paths, options)
+ tags = GrapeSwagger::DocMethods::TagNameDescription.build(paths)
+
+ if options[:tags]
+ names = options[:tags].map { |t| t[:name] }
+ tags.reject! { |t| names.include?(t[:name]) }
+ tags += options[:tags]
+ end
+
+ tags
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/doc_methods/parse_params.rb b/lib/grape-swagger/doc_methods/parse_params.rb
index 7e7c0c8c..91d4e259 100644
--- a/lib/grape-swagger/doc_methods/parse_params.rb
+++ b/lib/grape-swagger/doc_methods/parse_params.rb
@@ -29,6 +29,7 @@ def call(param, settings, path, route, definitions, consumes) # rubocop:disable
document_additional_properties(definitions, settings) unless value_type[:is_array]
document_add_extensions(settings)
document_example(settings)
+ document_nullable(settings)
@parsed_param
end
@@ -192,6 +193,10 @@ def parse_enum_or_range_values(values)
def parse_range_values(values)
{ minimum: values.begin, maximum: values.end }.compact
end
+
+ def document_nullable(settings)
+ @parsed_param[:'x-nullable'] = true if settings[:nullable]
+ end
end
end
end
diff --git a/lib/grape-swagger/doc_methods/webhooks.rb b/lib/grape-swagger/doc_methods/webhooks.rb
new file mode 100644
index 00000000..a237c9df
--- /dev/null
+++ b/lib/grape-swagger/doc_methods/webhooks.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module DocMethods
+ # Builds webhook path items from configuration for OpenAPI 3.1
+ module Webhooks
+ class << self
+ def apply(spec, webhooks_config)
+ return unless webhooks_config.is_a?(Hash)
+
+ webhooks_config.each do |name, webhook_def|
+ path_item = build_path_item(webhook_def)
+ spec.add_webhook(name.to_s, path_item)
+ end
+ end
+
+ def build_path_item(webhook_def)
+ path_item = GrapeSwagger::OpenAPI::PathItem.new
+
+ webhook_def.each do |method, operation_def|
+ next unless %i[get post put patch delete].include?(method.to_sym)
+
+ operation = build_operation(operation_def)
+ path_item.add_operation(method.to_sym, operation)
+ end
+
+ path_item
+ end
+
+ private
+
+ def build_operation(operation_def)
+ operation = GrapeSwagger::OpenAPI::Operation.new
+ operation.summary = operation_def[:summary]
+ operation.description = operation_def[:description]
+ operation.operation_id = operation_def[:operationId] || operation_def[:operation_id]
+ operation.tags = operation_def[:tags]
+
+ operation.request_body = build_request_body(operation_def[:requestBody]) if operation_def[:requestBody]
+
+ build_responses(operation, operation_def[:responses])
+
+ operation
+ end
+
+ def build_responses(operation, responses_def)
+ responses_def&.each do |code, response_def|
+ response = GrapeSwagger::OpenAPI::Response.new
+ response.description = response_def[:description] || ''
+ operation.add_response(code.to_s, response)
+ end
+ end
+
+ def build_request_body(request_body_def)
+ request_body = GrapeSwagger::OpenAPI::RequestBody.new
+ request_body.description = request_body_def[:description]
+ request_body.required = request_body_def[:required]
+
+ request_body_def[:content]&.each do |content_type, content_def|
+ schema = build_schema(content_def[:schema]) if content_def[:schema]
+ request_body.add_media_type(content_type.to_s, schema: schema)
+ end
+
+ request_body
+ end
+
+ def build_schema(schema_def)
+ return nil unless schema_def
+
+ if schema_def[:$ref] || schema_def['$ref']
+ ref = schema_def[:$ref] || schema_def['$ref']
+ schema = GrapeSwagger::OpenAPI::Schema.new
+ schema.canonical_name = ref.split('/').last
+ return schema
+ end
+
+ schema = GrapeSwagger::OpenAPI::Schema.new
+ schema.type = schema_def[:type]
+ schema.format = schema_def[:format]
+ schema.description = schema_def[:description]
+
+ schema_def[:properties]&.each do |prop_name, prop_def|
+ prop_schema = build_schema(prop_def)
+ schema.add_property(prop_name.to_s, prop_schema)
+ end
+
+ schema.required = Array(schema_def[:required]) if schema_def[:required]
+
+ schema
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb
index 2c7be5ed..7dd6c985 100644
--- a/lib/grape-swagger/endpoint.rb
+++ b/lib/grape-swagger/endpoint.rb
@@ -61,10 +61,22 @@ def info_object(infos)
# sub-objects of info object
# license
def license_object(infos)
- {
- name: infos.delete(:license),
- url: infos.delete(:license_url)
- }.delete_if { |_, value| value.blank? }
+ license = infos.delete(:license)
+ license_url = infos.delete(:license_url)
+
+ # Support both string and hash format for license
+ # Note: identifier is OAS 3.1 only, not included in Swagger 2.0 output
+ if license.is_a?(Hash)
+ {
+ name: license[:name],
+ url: license[:url] || license_url
+ }.delete_if { |_, value| value.blank? }
+ else
+ {
+ name: license,
+ url: license_url
+ }.delete_if { |_, value| value.blank? }
+ end
end
# contact
@@ -155,8 +167,8 @@ def description_object(route)
end
def produces_object(route, format)
- return ['application/octet-stream'] if file_response?(route.attributes.success) &&
- !route.attributes.produces.present?
+ return ['application/octet-stream'] if file_response?(route.options[:success]) &&
+ !route.options[:produces].present?
mime_types = GrapeSwagger::DocMethods::ProducesConsumes.call(format)
diff --git a/lib/grape-swagger/exporter.rb b/lib/grape-swagger/exporter.rb
new file mode 100644
index 00000000..03e44564
--- /dev/null
+++ b/lib/grape-swagger/exporter.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require_relative 'exporter/base'
+require_relative 'exporter/swagger2'
+require_relative 'exporter/oas30'
+require_relative 'exporter/oas31'
+
+module GrapeSwagger
+ module Exporter
+ # Exporters convert ApiModel::Spec to specific output formats.
+ # Each exporter produces a version-specific OpenAPI/Swagger document.
+
+ class << self
+ VERSION_EXPORTERS = {
+ swagger_20: Swagger2,
+ oas_30: OAS30,
+ oas_31: OAS31
+ }.freeze
+
+ # Factory method to get the appropriate exporter for a version
+ def for_version(version)
+ VERSION_EXPORTERS[normalize_version(version)] || Swagger2
+ end
+
+ # Export a spec using the specified version
+ def export(spec, version: nil)
+ exporter_class = for_version(version)
+ exporter_class.new(spec).export
+ end
+
+ private
+
+ def normalize_version(version)
+ return nil if version.nil?
+
+ case version.to_s.downcase
+ when '2.0', '2', 'swagger', 'swagger2', 'swagger_20'
+ :swagger_20
+ when '3.0', '3.0.0', '3.0.3', 'oas30', 'openapi30', 'openapi_30'
+ :oas_30
+ when '3.1', '3.1.0', 'oas31', 'openapi31', 'openapi_31'
+ :oas_31
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/base.rb b/lib/grape-swagger/exporter/base.rb
new file mode 100644
index 00000000..8ed4b47b
--- /dev/null
+++ b/lib/grape-swagger/exporter/base.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module Exporter
+ # Base exporter class for converting OpenAPI::Document to output format.
+ class Base
+ attr_reader :spec
+
+ def initialize(spec)
+ @spec = spec
+ end
+
+ def export
+ raise NotImplementedError, 'Subclasses must implement #export'
+ end
+
+ protected
+
+ # Deep convert symbols to strings in hash keys
+ def stringify_keys(hash)
+ case hash
+ when Hash
+ hash.each_with_object({}) do |(k, v), result|
+ result[k.to_s] = stringify_keys(v)
+ end
+ when Array
+ hash.map { |v| stringify_keys(v) }
+ else
+ hash
+ end
+ end
+
+ # Deep convert strings to symbols in hash keys
+ def symbolize_keys(hash)
+ case hash
+ when Hash
+ hash.each_with_object({}) do |(k, v), result|
+ result[k.to_sym] = symbolize_keys(v)
+ end
+ when Array
+ hash.map { |v| symbolize_keys(v) }
+ else
+ hash
+ end
+ end
+
+ # Remove nil values and empty containers from hash, but preserve intentionally empty arrays
+ # like security: [] or scopes: []
+ def compact_hash(hash, preserve_empty_arrays: false)
+ case hash
+ when Hash
+ hash.each_with_object({}) do |(k, v), result|
+ # Preserve empty arrays in certain contexts (e.g., security scopes)
+ if v.is_a?(Array) && v.empty? && preserve_empty_arrays
+ result[k] = v
+ else
+ compacted = compact_hash(v, preserve_empty_arrays: true)
+ result[k] = compacted unless blank?(compacted)
+ end
+ end
+ when Array
+ # Don't reject empty hashes from arrays (e.g., security: [{api_key: []}])
+ hash.map { |v| compact_hash(v, preserve_empty_arrays: preserve_empty_arrays) }.compact
+ else
+ hash
+ end
+ end
+
+ def blank?(value)
+ return true if value.nil?
+ # Only consider empty if it's an empty hash (not array - arrays can be intentionally empty)
+ return true if value.is_a?(Hash) && value.empty?
+
+ false
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/oas30.rb b/lib/grape-swagger/exporter/oas30.rb
new file mode 100644
index 00000000..7cbdceec
--- /dev/null
+++ b/lib/grape-swagger/exporter/oas30.rb
@@ -0,0 +1,327 @@
+# frozen_string_literal: true
+
+require_relative 'schema_exporter'
+
+module GrapeSwagger
+ module Exporter
+ # Exports OpenAPI::Document to OpenAPI 3.0 format.
+ class OAS30 < Base
+ include SchemaExporter
+
+ def export
+ output = {}
+
+ output[:openapi] = openapi_version
+ output[:info] = export_info
+ output[:servers] = export_servers if servers.any?
+ output[:tags] = export_tags if spec.tags.any?
+ output[:paths] = export_paths if spec.paths.any?
+ output[:components] = export_components unless components_empty?
+ output[:security] = spec.security unless spec.security.nil?
+
+ # Extensions
+ spec.extensions.each { |k, v| output[k] = v }
+
+ compact_hash(output)
+ end
+
+ protected
+
+ def openapi_version
+ '3.0.3'
+ end
+
+ def nullable_keyword?
+ true
+ end
+
+ def servers
+ return spec.servers if spec.servers.any?
+ return [] unless spec.host
+
+ # Build servers from Swagger 2.0 host/basePath/schemes
+ schemes = spec.schemes.presence || ['https']
+ schemes.map do |scheme|
+ OpenAPI::Server.from_swagger2(
+ host: spec.host,
+ base_path: spec.base_path,
+ scheme: scheme
+ )
+ end
+ end
+
+ def components_empty?
+ spec.components.schemas.empty? &&
+ spec.components.security_schemes.empty? &&
+ spec.components.links.empty? &&
+ spec.components.callbacks.empty?
+ end
+
+ private
+
+ def export_info
+ info = {}
+ info[:title] = spec.info.title || 'API title'
+ info[:description] = spec.info.description if spec.info.description
+ info[:termsOfService] = spec.info.terms_of_service if spec.info.terms_of_service
+ info[:version] = spec.info.version || '1.0'
+
+ info[:contact] = spec.info.contact if spec.info.contact
+ info[:license] = export_license if spec.info.license
+
+ spec.info.extensions.each { |k, v| info[k] = v }
+
+ info
+ end
+
+ def export_license
+ license = spec.info.license.dup
+ # OAS 3.0 doesn't support identifier, only url
+ license.delete(:identifier)
+ license
+ end
+
+ def export_servers
+ servers.map do |server|
+ output = { url: server.url }
+ output[:description] = server.description if server.description
+ output[:variables] = server.variables.transform_values(&:to_h) if server.variables&.any?
+ output
+ end
+ end
+
+ def export_tags
+ spec.tags.map do |tag|
+ tag_hash = { name: tag.name }
+ tag_hash[:description] = tag.description if tag.description
+ tag_hash[:externalDocs] = tag.external_docs.to_h if tag.external_docs
+ tag.extensions.each { |k, v| tag_hash[k] = v }
+ tag_hash
+ end
+ end
+
+ def export_paths
+ spec.paths.transform_values do |path_item|
+ export_path_item(path_item)
+ end
+ end
+
+ def export_path_item(path_item)
+ output = {}
+
+ output[:summary] = path_item.summary if path_item.summary
+ output[:description] = path_item.description if path_item.description
+ output[:servers] = path_item.servers.map(&:to_h) if path_item.servers&.any?
+ output[:parameters] = path_item.parameters.map { |p| export_parameter(p) } if path_item.parameters.any?
+
+ path_item.operations.each do |method, operation|
+ output[method.to_sym] = export_operation(operation)
+ end
+
+ path_item.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_operation(operation)
+ output = {}
+
+ output[:operationId] = operation.operation_id if operation.operation_id
+ output[:summary] = operation.summary if operation.summary
+ output[:description] = operation.description if operation.description
+ output[:tags] = operation.tags if operation.tags&.any?
+ output[:deprecated] = operation.deprecated if operation.deprecated
+ output[:security] = operation.security unless operation.security.nil?
+
+ # Parameters (OAS3 style with schema wrapper)
+ params = operation.parameters.map { |p| export_parameter(p) }
+ output[:parameters] = params if params.any?
+
+ # Request body (OAS3 specific)
+ output[:requestBody] = export_request_body(operation.request_body) if operation.request_body
+
+ # Responses
+ output[:responses] = export_responses(operation.responses) if operation.responses.any?
+
+ # Callbacks (OAS3 specific)
+ output[:callbacks] = operation.callbacks if operation.callbacks&.any?
+
+ operation.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_parameter(param)
+ output = {}
+
+ output[:name] = param.name
+ output[:in] = param.location
+ output[:description] = param.description if param.description
+ output[:required] = param.required
+
+ # OAS3 requires schema wrapper
+ output[:schema] = export_parameter_schema(param)
+
+ # Style and explode (OAS3)
+ if param.collection_format
+ output[:style] = param.style_from_collection_format
+ output[:explode] = param.explode_from_collection_format
+ elsif param.style
+ output[:style] = param.style
+ output[:explode] = param.explode unless param.explode.nil?
+ end
+
+ output[:deprecated] = param.deprecated if param.deprecated
+ output[:allowEmptyValue] = param.allow_empty_value if param.allow_empty_value
+ output[:example] = param.example unless param.example.nil?
+ output[:examples] = param.examples if param.examples&.any?
+
+ param.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_parameter_schema(param)
+ if param.schema
+ export_schema(param.schema)
+ else
+ # Build schema from inline properties
+ schema = {}
+ schema[:type] = param.type if param.type
+ schema[:format] = param.format if param.format
+ schema[:items] = export_schema(param.items) if param.items
+ schema[:default] = param.default unless param.default.nil?
+ schema[:enum] = param.enum if param.enum&.any?
+ schema[:minimum] = param.minimum if param.minimum
+ schema[:maximum] = param.maximum if param.maximum
+ schema[:minLength] = param.min_length if param.min_length
+ schema[:maxLength] = param.max_length if param.max_length
+ schema[:pattern] = param.pattern if param.pattern
+ schema
+ end
+ end
+
+ def export_request_body(request_body)
+ return nil unless request_body
+
+ output = {}
+ output[:description] = request_body.description if request_body.description
+ output[:required] = request_body.required unless request_body.required.nil?
+ output[:content] = export_content(request_body.media_types) if request_body.media_types.any?
+
+ request_body.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_content(media_types)
+ media_types.each_with_object({}) do |mt, result|
+ content = {}
+ content[:schema] = export_schema(mt.schema) if mt.schema
+ content[:example] = mt.example unless mt.example.nil?
+ content[:examples] = mt.examples if mt.examples&.any?
+ content[:encoding] = mt.encoding if mt.encoding&.any?
+ mt.extensions.each { |k, v| content[k] = v }
+ result[mt.mime_type] = content
+ end
+ end
+
+ def export_responses(responses)
+ responses.each_with_object({}) do |(code, response), result|
+ result[code.to_s] = export_response(response)
+ end
+ end
+
+ def export_response(response)
+ output = { description: response.description || '' }
+
+ # Content with schema (OAS3 style)
+ if response.media_types.any?
+ output[:content] = export_content(response.media_types)
+ elsif response.schema
+ # Convert schema to content
+ output[:content] = {
+ 'application/json' => { schema: export_schema(response.schema) }
+ }
+ end
+
+ if response.headers.any?
+ output[:headers] = response.headers.transform_values do |header|
+ export_header(header)
+ end
+ end
+
+ output[:links] = response.links if response.links&.any?
+
+ response.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_header(header)
+ output = {}
+ output[:description] = header.description if header.description
+ output[:required] = header.required if header.required
+ output[:deprecated] = header.deprecated if header.deprecated
+
+ # OAS3 requires schema wrapper for headers
+ if header.schema
+ output[:schema] = export_schema(header.schema)
+ else
+ schema = {}
+ schema[:type] = header.type if header.type
+ schema[:format] = header.format if header.format
+ output[:schema] = schema unless schema.empty?
+ end
+
+ header.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_components
+ output = {}
+
+ if spec.components.schemas.any?
+ output[:schemas] = spec.components.schemas.transform_values do |schema|
+ export_schema(schema)
+ end
+ end
+
+ if spec.components.security_schemes.any?
+ output[:securitySchemes] = spec.components.security_schemes.transform_values do |scheme|
+ export_security_scheme(scheme)
+ end
+ end
+
+ # OAS3 components: links and callbacks
+ output[:links] = spec.components.links if spec.components.links.any?
+ output[:callbacks] = spec.components.callbacks if spec.components.callbacks.any?
+
+ output
+ end
+
+ def export_security_scheme(scheme)
+ output = { type: scheme.type }
+ output[:description] = scheme.description if scheme.description
+
+ case scheme.type
+ when 'apiKey'
+ output[:name] = scheme.name
+ output[:in] = scheme.location
+ when 'http'
+ output[:scheme] = scheme.scheme
+ output[:bearerFormat] = scheme.bearer_format if scheme.bearer_format
+ when 'oauth2'
+ output[:flows] = scheme.flows if scheme.flows
+ when 'openIdConnect'
+ output[:openIdConnectUrl] = scheme.open_id_connect_url
+ end
+
+ scheme.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/oas31.rb b/lib/grape-swagger/exporter/oas31.rb
new file mode 100644
index 00000000..cff39c77
--- /dev/null
+++ b/lib/grape-swagger/exporter/oas31.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module Exporter
+ # Exports OpenAPI::Document to OpenAPI 3.1 format.
+ # Extends OAS30 with 3.1-specific differences.
+ class OAS31 < OAS30
+ def export
+ output = {}
+
+ output[:openapi] = openapi_version
+
+ # OAS 3.1: jsonSchemaDialect (before info)
+ output[:jsonSchemaDialect] = spec.json_schema_dialect if spec.json_schema_dialect
+
+ output[:info] = export_info
+ output[:servers] = export_servers if servers.any?
+ output[:tags] = export_tags if spec.tags.any?
+ output[:paths] = export_paths if spec.paths.any?
+
+ # OAS 3.1: webhooks
+ output[:webhooks] = export_webhooks if spec.webhooks.any?
+
+ output[:components] = export_components unless components_empty?
+ output[:security] = spec.security unless spec.security.nil?
+
+ # Extensions
+ spec.extensions.each { |k, v| output[k] = v }
+
+ compact_hash(output)
+ end
+
+ protected
+
+ def openapi_version
+ '3.1.0'
+ end
+
+ # OAS 3.1 uses type array for nullable instead of nullable keyword
+ def nullable_keyword?
+ false
+ end
+
+ def export_license
+ license = spec.info.license.dup
+
+ # OAS 3.1 supports identifier OR url (not both)
+ # If identifier is present, prefer it over url
+ license.delete(:url) if license[:identifier]
+
+ license
+ end
+
+ def export_webhooks
+ spec.webhooks.transform_values do |path_item|
+ export_path_item(path_item)
+ end
+ end
+
+ # OAS 3.1 specific schema building - extends parent with 3.1 features
+ def build_schema_output(schema)
+ output = {}
+ add_oas31_json_schema(output, schema)
+ add_schema_basic_fields(output, schema)
+ add_oas31_content_fields(output, schema)
+ add_schema_nullable(output, schema)
+ add_schema_flags(output, schema)
+ add_schema_numeric_constraints(output, schema)
+ add_schema_string_constraints(output, schema)
+ add_schema_array_fields(output, schema)
+ add_schema_object_fields(output, schema)
+ add_schema_composition(output, schema)
+ add_schema_extensions(output, schema)
+ output
+ end
+
+ private
+
+ def add_oas31_json_schema(output, schema)
+ return unless schema.respond_to?(:json_schema) && schema.json_schema
+
+ output[:$schema] = schema.json_schema
+ end
+
+ def add_oas31_content_fields(output, schema)
+ if schema.respond_to?(:content_media_type) && schema.content_media_type
+ output[:contentMediaType] = schema.content_media_type
+ end
+ return unless schema.respond_to?(:content_encoding) && schema.content_encoding
+
+ output[:contentEncoding] = schema.content_encoding
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/schema_exporter.rb b/lib/grape-swagger/exporter/schema_exporter.rb
new file mode 100644
index 00000000..cbd2b3d9
--- /dev/null
+++ b/lib/grape-swagger/exporter/schema_exporter.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require_relative 'schema_fields'
+
+module GrapeSwagger
+ module Exporter
+ # Shared schema export methods for OAS 3.x exporters
+ module SchemaExporter
+ include SchemaFields
+
+ def export_schema(schema)
+ return nil unless schema
+ return schema_ref(schema) if schema_is_ref?(schema)
+ return schema_ref_with_description(schema) if schema_is_ref_with_description?(schema)
+ return export_hash_schema(schema) if schema.is_a?(Hash)
+
+ build_schema_output(schema)
+ end
+
+ private
+
+ def schema_is_ref_with_description?(schema)
+ schema.respond_to?(:canonical_name) && schema.canonical_name && !schema.type && schema.description
+ end
+
+ def schema_is_ref?(schema)
+ schema.respond_to?(:canonical_name) && schema.canonical_name && !schema.type && !schema.description
+ end
+
+ def schema_ref(schema)
+ { '$ref' => "#/components/schemas/#{schema.canonical_name}" }
+ end
+
+ def schema_ref_with_description(schema)
+ {
+ 'allOf' => [{ '$ref' => "#/components/schemas/#{schema.canonical_name}" }],
+ 'description' => schema.description
+ }
+ end
+
+ def build_schema_output(schema)
+ output = {}
+ add_schema_basic_fields(output, schema)
+ add_schema_nullable(output, schema)
+ add_schema_flags(output, schema)
+ add_schema_numeric_constraints(output, schema)
+ add_schema_string_constraints(output, schema)
+ add_schema_array_fields(output, schema)
+ add_schema_object_fields(output, schema)
+ add_schema_composition(output, schema)
+ add_schema_extensions(output, schema)
+ output
+ end
+
+ def export_hash_schema(schema)
+ if schema['$ref'] || schema[:$ref]
+ ref = schema['$ref'] || schema[:$ref]
+ ref = ref.gsub('#/definitions/', '#/components/schemas/')
+ return { '$ref' => ref }
+ end
+
+ schema
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/schema_fields.rb b/lib/grape-swagger/exporter/schema_fields.rb
new file mode 100644
index 00000000..b169b087
--- /dev/null
+++ b/lib/grape-swagger/exporter/schema_fields.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module Exporter
+ # Schema field helper methods for OAS 3.x exporters
+ module SchemaFields
+ private
+
+ def add_schema_basic_fields(output, schema)
+ add_schema_type(output, schema)
+ output[:format] = schema.format if schema.format
+ output[:description] = schema.description if schema.description
+ output[:enum] = schema.enum if schema.enum&.any?
+ output[:default] = schema.default unless schema.default.nil?
+ output[:example] = schema.example unless schema.example.nil?
+ end
+
+ def add_schema_type(output, schema)
+ return unless schema.type
+
+ if schema.type == 'null' && nullable_keyword?
+ output[:nullable] = true
+ else
+ output[:type] = schema.type
+ end
+ end
+
+ def add_schema_nullable(output, schema)
+ return unless schema.nullable
+
+ if nullable_keyword?
+ output[:nullable] = true
+ elsif output[:type]
+ output[:type] = [output[:type], 'null']
+ end
+ end
+
+ def add_schema_flags(output, schema)
+ output[:readOnly] = schema.read_only if schema.read_only
+ output[:writeOnly] = schema.write_only if schema.write_only
+ output[:deprecated] = schema.deprecated if schema.deprecated
+ end
+
+ def add_schema_numeric_constraints(output, schema)
+ output[:minimum] = schema.minimum if schema.minimum
+ output[:maximum] = schema.maximum if schema.maximum
+ output[:exclusiveMinimum] = schema.exclusive_minimum if schema.exclusive_minimum
+ output[:exclusiveMaximum] = schema.exclusive_maximum if schema.exclusive_maximum
+ output[:multipleOf] = schema.multiple_of if schema.multiple_of
+ end
+
+ def add_schema_string_constraints(output, schema)
+ output[:minLength] = schema.min_length if schema.min_length
+ output[:maxLength] = schema.max_length if schema.max_length
+ output[:pattern] = schema.pattern if schema.pattern
+ end
+
+ def add_schema_array_fields(output, schema)
+ output[:items] = export_schema(schema.items) if schema.items
+ output[:minItems] = schema.min_items if schema.min_items
+ output[:maxItems] = schema.max_items if schema.max_items
+ end
+
+ def add_schema_object_fields(output, schema)
+ output[:properties] = schema.properties.transform_values { |s| export_schema(s) } if schema.properties.any?
+ output[:required] = schema.required if schema.required.any?
+ return if schema.additional_properties.nil?
+
+ output[:additionalProperties] = export_additional_properties(schema.additional_properties)
+ end
+
+ def export_additional_properties(additional_props)
+ return additional_props if [true, false].include?(additional_props)
+
+ if additional_props.is_a?(Hash)
+ if additional_props['$ref'] || additional_props[:$ref]
+ ref = additional_props['$ref'] || additional_props[:$ref]
+ ref = ref.gsub('#/definitions/', '#/components/schemas/')
+ return { '$ref' => ref }
+ end
+
+ if additional_props[:canonical_name]
+ return { '$ref' => "#/components/schemas/#{additional_props[:canonical_name]}" }
+ end
+
+ return additional_props
+ end
+
+ additional_props
+ end
+
+ def add_schema_composition(output, schema)
+ output[:allOf] = schema.all_of.map { |s| export_schema(s) } if schema.all_of&.any?
+ output[:oneOf] = schema.one_of.map { |s| export_schema(s) } if schema.one_of&.any?
+ output[:anyOf] = schema.any_of.map { |s| export_schema(s) } if schema.any_of&.any?
+ output[:not] = export_schema(schema.not) if schema.not
+ output[:discriminator] = schema.discriminator if schema.discriminator
+ end
+
+ def add_schema_extensions(output, schema)
+ schema.extensions&.each { |k, v| output[k] = v }
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/exporter/swagger2.rb b/lib/grape-swagger/exporter/swagger2.rb
new file mode 100644
index 00000000..907b26b0
--- /dev/null
+++ b/lib/grape-swagger/exporter/swagger2.rb
@@ -0,0 +1,346 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module Exporter
+ # Exports OpenAPI::Document to Swagger 2.0 format.
+ # This exporter produces output compatible with the original grape-swagger format.
+ class Swagger2 < Base
+ OAUTH_FLOW_MAP = {
+ 'implicit' => 'implicit',
+ 'password' => 'password',
+ 'clientCredentials' => 'application',
+ 'authorizationCode' => 'accessCode'
+ }.freeze
+
+ def export
+ output = { swagger: '2.0', info: export_info }
+ add_root_fields(output)
+ add_content_types(output)
+ add_main_sections(output)
+ spec.extensions.each { |k, v| output[k] = v }
+ compact_hash(output)
+ end
+
+ private
+
+ def add_root_fields(output)
+ output[:host] = spec.host if spec.host
+ output[:basePath] = spec.base_path if spec.base_path
+ output[:schemes] = spec.schemes if spec.schemes&.any?
+ end
+
+ def add_content_types(output)
+ output[:produces] = spec.produces if spec.produces&.any?
+ output[:consumes] = spec.consumes if spec.consumes&.any?
+ end
+
+ def add_main_sections(output)
+ output[:tags] = export_tags if spec.tags.any?
+ output[:paths] = export_paths if spec.paths.any?
+ output[:definitions] = export_definitions if spec.components.schemas.any?
+ output[:securityDefinitions] = export_security_definitions if spec.components.security_schemes.any?
+ output[:security] = spec.security if spec.security&.any?
+ end
+
+ def export_info
+ info = {}
+ info[:title] = spec.info.title || 'API title'
+ info[:description] = spec.info.description if spec.info.description
+ info[:termsOfService] = spec.info.terms_of_service if spec.info.terms_of_service
+ info[:version] = spec.info.version || '1.0'
+
+ if spec.info.contact
+ contact = spec.info.contact.dup
+ contact.delete(:identifier) # Not in Swagger 2.0
+ info[:contact] = contact unless contact.empty?
+ end
+
+ if spec.info.license
+ license = spec.info.license.dup
+ license.delete(:identifier) # Not in Swagger 2.0
+ info[:license] = license unless license.empty?
+ end
+
+ spec.info.extensions.each { |k, v| info[k] = v }
+
+ info
+ end
+
+ def export_tags
+ spec.tags.map do |tag|
+ tag_hash = { name: tag.name }
+ tag_hash[:description] = tag.description if tag.description
+ tag_hash[:externalDocs] = tag.external_docs.to_h if tag.external_docs
+ tag.extensions.each { |k, v| tag_hash[k] = v }
+ tag_hash
+ end
+ end
+
+ def export_paths
+ spec.paths.transform_values do |path_item|
+ export_path_item(path_item)
+ end
+ end
+
+ def export_path_item(path_item)
+ output = {}
+
+ output[:parameters] = path_item.parameters.map { |p| export_parameter(p) } if path_item.parameters.any?
+
+ path_item.operations.each do |method, operation|
+ output[method.to_sym] = export_operation(operation)
+ end
+
+ path_item.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_operation(operation)
+ output = {}
+
+ output[:operationId] = operation.operation_id if operation.operation_id
+ output[:summary] = operation.summary if operation.summary
+ output[:description] = operation.description if operation.description
+ output[:tags] = operation.tags if operation.tags&.any?
+ output[:produces] = operation.produces if operation.produces&.any?
+ output[:consumes] = operation.consumes if operation.consumes&.any?
+ output[:deprecated] = operation.deprecated if operation.deprecated
+ output[:security] = operation.security if operation.security&.any?
+
+ # Parameters (including body from request_body)
+ params = export_operation_parameters(operation)
+ output[:parameters] = params if params.any?
+
+ # Responses
+ output[:responses] = export_responses(operation.responses) if operation.responses.any?
+
+ operation.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_operation_parameters(operation)
+ params = operation.parameters.map { |p| export_parameter(p) }
+
+ # Convert request body back to body parameter
+ params << export_request_body_as_parameter(operation.request_body) if operation.request_body
+
+ params.compact
+ end
+
+ def export_parameter(param)
+ output = { name: param.name, in: param.location, required: param.required }
+ output[:description] = param.description if param.description
+ add_param_type_fields(output, param)
+ param.extensions.each { |k, v| output[k] = v }
+ output
+ end
+
+ def add_param_type_fields(output, param)
+ if param.type
+ add_inline_type_fields(output, param)
+ elsif param.schema
+ add_schema_type_fields(output, param.schema)
+ end
+ end
+
+ def add_inline_type_fields(output, param)
+ output[:type] = param.type
+ output[:format] = param.format if param.format
+ output[:items] = export_items(param.items) if param.items
+ output[:collectionFormat] = param.collection_format if param.collection_format
+ add_common_constraints(output, param)
+ end
+
+ def add_schema_type_fields(output, schema)
+ output[:type] = schema.type if schema.type
+ output[:format] = schema.format if schema.format
+ output[:items] = export_schema(schema.items) if schema.items
+ add_common_constraints(output, schema)
+ end
+
+ def add_common_constraints(output, source)
+ output[:default] = source.default unless source.default.nil?
+ output[:enum] = source.enum if source.enum&.any?
+ output[:minimum] = source.minimum if source.minimum
+ output[:maximum] = source.maximum if source.maximum
+ output[:minLength] = source.min_length if source.min_length
+ output[:maxLength] = source.max_length if source.max_length
+ output[:pattern] = source.pattern if source.pattern
+ end
+
+ def export_request_body_as_parameter(request_body)
+ return nil unless request_body.media_types.any?
+
+ primary = request_body.media_types.first
+ return nil unless primary&.schema
+
+ output = {
+ name: 'body',
+ in: 'body',
+ required: request_body.required
+ }
+ output[:description] = request_body.description if request_body.description
+ output[:schema] = export_schema(primary.schema)
+
+ output
+ end
+
+ def export_responses(responses)
+ responses.each_with_object({}) do |(code, response), result|
+ result[code.to_s] = export_response(response)
+ end
+ end
+
+ def export_response(response)
+ output = { description: response.description || '' }
+
+ # Use stored schema or extract from media types
+ schema = response.schema
+ schema ||= response.media_types.first&.schema if response.media_types.any?
+
+ output[:schema] = export_schema(schema) if schema
+
+ if response.headers.any?
+ output[:headers] = response.headers.transform_values do |header|
+ export_header(header)
+ end
+ end
+
+ output[:examples] = response.examples if response.examples&.any?
+
+ response.extensions.each { |k, v| output[k] = v }
+
+ output
+ end
+
+ def export_header(header)
+ output = {}
+ output[:description] = header.description if header.description
+ output[:type] = header.type || header.schema&.type
+ output[:format] = header.format || header.schema&.format
+ header.extensions.each { |k, v| output[k] = v }
+ output
+ end
+
+ def export_definitions
+ spec.components.schemas.transform_values do |schema|
+ export_schema(schema)
+ end
+ end
+
+ def export_schema(schema)
+ return nil unless schema
+ return { '$ref' => "#/definitions/#{schema.canonical_name}" } if schema.canonical_name && !schema.type
+
+ build_swagger2_schema(schema)
+ end
+
+ def build_swagger2_schema(schema)
+ output = {}
+ add_swagger2_basic_fields(output, schema)
+ add_swagger2_numeric_constraints(output, schema)
+ add_swagger2_string_constraints(output, schema)
+ add_swagger2_array_fields(output, schema)
+ add_swagger2_object_fields(output, schema)
+ add_swagger2_composition(output, schema)
+ schema.extensions&.each { |k, v| output[k] = v }
+ output
+ end
+
+ def add_swagger2_basic_fields(output, schema)
+ output[:type] = schema.type if schema.type
+ output[:format] = schema.format if schema.format
+ output[:description] = schema.description if schema.description
+ output[:enum] = schema.enum if schema.enum&.any?
+ output[:default] = schema.default unless schema.default.nil?
+ output[:example] = schema.example unless schema.example.nil?
+ end
+
+ def add_swagger2_numeric_constraints(output, schema)
+ output[:minimum] = schema.minimum if schema.minimum
+ output[:maximum] = schema.maximum if schema.maximum
+ output[:exclusiveMinimum] = schema.exclusive_minimum if schema.exclusive_minimum
+ output[:exclusiveMaximum] = schema.exclusive_maximum if schema.exclusive_maximum
+ output[:multipleOf] = schema.multiple_of if schema.multiple_of
+ end
+
+ def add_swagger2_string_constraints(output, schema)
+ output[:minLength] = schema.min_length if schema.min_length
+ output[:maxLength] = schema.max_length if schema.max_length
+ output[:pattern] = schema.pattern if schema.pattern
+ end
+
+ def add_swagger2_array_fields(output, schema)
+ output[:items] = export_schema(schema.items) if schema.items
+ output[:minItems] = schema.min_items if schema.min_items
+ output[:maxItems] = schema.max_items if schema.max_items
+ end
+
+ def add_swagger2_object_fields(output, schema)
+ output[:properties] = schema.properties.transform_values { |s| export_schema(s) } if schema.properties.any?
+ output[:required] = schema.required if schema.required.any?
+ output[:additionalProperties] = schema.additional_properties unless schema.additional_properties.nil?
+ end
+
+ def add_swagger2_composition(output, schema)
+ output[:allOf] = schema.all_of.map { |s| export_schema(s) } if schema.all_of&.any?
+ output[:discriminator] = schema.discriminator if schema.discriminator
+ end
+
+ def export_items(items)
+ return items if items.is_a?(Hash)
+ return export_schema(items) if items.is_a?(OpenAPI::Schema)
+
+ items
+ end
+
+ def export_security_definitions
+ spec.components.security_schemes.transform_values do |scheme|
+ export_security_scheme(scheme)
+ end
+ end
+
+ def export_security_scheme(scheme)
+ output = build_security_type_fields(scheme)
+ output[:description] = scheme.description if scheme.description
+ scheme.extensions.each { |k, v| output[k] = v }
+ output
+ end
+
+ def build_security_type_fields(scheme)
+ case scheme.type
+ when 'http' then build_http_security(scheme)
+ when 'apiKey' then { type: 'apiKey', name: scheme.name, in: scheme.location }
+ when 'oauth2' then build_oauth2_security(scheme)
+ else { type: scheme.type }
+ end
+ end
+
+ def build_http_security(scheme)
+ if scheme.scheme == 'basic'
+ { type: 'basic' }
+ else
+ { type: 'apiKey', name: scheme.name || 'Authorization', in: 'header' }
+ end
+ end
+
+ def build_oauth2_security(scheme)
+ output = { type: 'oauth2' }
+ return output unless scheme.flows
+
+ flow_type, flow = scheme.flows.first
+ output[:flow] = convert_oauth_flow_type(flow_type)
+ output[:authorizationUrl] = flow[:authorizationUrl] if flow[:authorizationUrl]
+ output[:tokenUrl] = flow[:tokenUrl] if flow[:tokenUrl]
+ output[:scopes] = flow[:scopes] if flow[:scopes]
+ output
+ end
+
+ def convert_oauth_flow_type(oas3_flow)
+ OAUTH_FLOW_MAP[oas3_flow.to_s] || oas3_flow.to_s
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi.rb b/lib/grape-swagger/openapi.rb
new file mode 100644
index 00000000..0dc15da5
--- /dev/null
+++ b/lib/grape-swagger/openapi.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require_relative 'openapi/schema'
+require_relative 'openapi/info'
+require_relative 'openapi/server'
+require_relative 'openapi/media_type'
+require_relative 'openapi/parameter'
+require_relative 'openapi/request_body'
+require_relative 'openapi/response'
+require_relative 'openapi/operation'
+require_relative 'openapi/path_item'
+require_relative 'openapi/security_scheme'
+require_relative 'openapi/tag'
+require_relative 'openapi/components'
+require_relative 'openapi/document'
+
+module GrapeSwagger
+ module OpenAPI
+ # Version-agnostic API model for OpenAPI/Swagger specifications.
+ # This layer provides a unified representation that can be exported
+ # to Swagger 2.0, OpenAPI 3.0, or OpenAPI 3.1 formats.
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder.rb b/lib/grape-swagger/openapi/builder.rb
new file mode 100644
index 00000000..cf8e40b5
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder.rb
@@ -0,0 +1,219 @@
+# frozen_string_literal: true
+
+require_relative 'builder/concerns/info'
+require_relative 'builder/concerns/servers'
+require_relative 'builder/concerns/security'
+require_relative 'builder/concerns/operations'
+require_relative 'builder/concerns/tags'
+require_relative 'builder/concerns/parameters'
+require_relative 'builder/concerns/request_body'
+require_relative 'builder/concerns/responses'
+require_relative 'builder/concerns/schemas'
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI::Document directly from Grape routes.
+ # This preserves all Grape options that would otherwise be lost in conversion (e.g., allow_blank → nullable).
+ #
+ # Architecture:
+ # Grape Routes → Builder::Spec → OpenAPI Model → Exporter → OAS3 Output
+ #
+ # This is the active path for OAS3 generation. The Swagger 2.0 path remains unchanged:
+ # Grape Routes → endpoint.rb → Swagger Hash
+ class Spec
+ include InfoBuilder
+ include ServerBuilder
+ include SecurityBuilder
+ include OperationBuilder
+ include TagBuilder
+ include ParameterBuilder
+ include RequestBodyBuilder
+ include ResponseBuilder
+
+ attr_reader :spec, :definitions, :options
+
+ def initialize(endpoint, target_class, request, options)
+ @endpoint = endpoint
+ @target_class = target_class
+ @request = request
+ @options = options
+ @definitions = {}
+ @spec = OpenAPI::Document.new
+ @schema_builder = SchemaBuilder.new(@definitions)
+ end
+
+ def build(namespace_routes)
+ # Initialize @definitions on endpoint so model parsers can use it
+ @endpoint.instance_variable_set(:@definitions, @definitions)
+
+ build_info
+ build_servers
+ build_content_types
+ build_security_definitions
+ build_paths(namespace_routes)
+ build_tags
+ build_extensions
+
+ @spec
+ end
+
+ private
+
+ # ==================== Content Types ====================
+
+ def build_content_types
+ @spec.produces = options[:produces] || content_types_for_target
+ @spec.consumes = options[:consumes]
+ end
+
+ def content_types_for_target
+ @endpoint.content_types_for(@target_class)
+ end
+
+ # ==================== Paths ====================
+
+ def build_paths(namespace_routes)
+ # Add models from options
+ add_definitions_from(options[:models])
+
+ namespace_routes.each_value do |routes|
+ routes.each do |route|
+ next if hidden?(route)
+
+ build_path_item(route)
+ end
+ end
+ end
+
+ def add_definitions_from(models)
+ return unless models
+
+ models.each { |model| expose_params_from_model(model) }
+ end
+
+ def build_path_item(route)
+ @current_item, path = GrapeSwagger::DocMethods::PathString.build(route, options)
+ @current_entity = route.entity || route.options[:success]
+
+ path_item = @spec.paths[path] || OpenAPI::PathItem.new(path: path)
+ operation = build_operation(route, path)
+ path_item.add_operation(route.request_method.downcase.to_sym, operation)
+
+ @spec.add_path(path, path_item)
+
+ # Handle path-level extensions
+ add_path_extensions(path_item, route)
+ end
+
+ def add_path_extensions(path_item, route)
+ x_path = route.settings[:x_path]
+ return unless x_path
+
+ x_path.each do |key, value|
+ path_item.extensions["x-#{key}"] = value
+ end
+ end
+
+ # ==================== Extensions ====================
+
+ def build_extensions
+ GrapeSwagger::DocMethods::Extensions.add_extensions_to_root(options, @spec.extensions)
+ end
+
+ # ==================== Helpers ====================
+
+ def hidden?(route)
+ route_hidden = route.settings.try(:[], :swagger).try(:[], :hidden)
+ route_hidden = route.options[:hidden] if route.options.key?(:hidden)
+ return route_hidden unless route_hidden.is_a?(Proc)
+
+ return route_hidden.call unless options[:token_owner]
+
+ token_owner = GrapeSwagger::TokenOwnerResolver.resolve(@endpoint, options[:token_owner])
+ GrapeSwagger::TokenOwnerResolver.evaluate_proc(route_hidden, token_owner)
+ end
+
+ def hidden_parameter?(param_options)
+ return false if param_options[:required]
+
+ doc = param_options[:documentation] || {}
+ hidden = doc[:hidden]
+
+ if hidden.is_a?(Proc)
+ hidden.call
+ else
+ hidden
+ end
+ end
+
+ def file_response?(value)
+ value.to_s.casecmp('file').zero?
+ end
+
+ def expose_params_from_model(model)
+ # Handle array format (from failure codes) or empty/nil values
+ return nil if model.nil? || model.is_a?(Array)
+ return nil if model.is_a?(String) && model.strip.empty?
+
+ model = model.constantize if model.is_a?(String)
+ model_name = GrapeSwagger::DocMethods::DataType.parse_entity_name(model)
+
+ return model_name if @definitions.key?(model_name)
+
+ @definitions[model_name] = nil
+
+ parser = GrapeSwagger.model_parsers.find(model)
+ raise GrapeSwagger::Errors::UnregisteredParser, "No parser registered for #{model_name}." unless parser
+
+ parsed_response = parser.new(model, self).call
+
+ if parsed_response.is_a?(OpenAPI::Schema)
+ schema = parsed_response
+ schema.canonical_name ||= model_name
+ @definitions[model_name] = schema.to_h
+ @spec.components.add_schema(model_name, schema)
+ return model_name
+ end
+
+ definition = GrapeSwagger::DocMethods::BuildModelDefinition.parse_params_from_model(
+ parsed_response, model, model_name
+ )
+
+ @definitions[model_name] = definition
+
+ # Recursively expose nested models referenced by $ref
+ expose_nested_refs(definition)
+
+ # Convert definition to schema and add to components
+ schema = @schema_builder.build_from_definition(definition)
+ schema.canonical_name = model_name
+ @spec.components.add_schema(model_name, schema)
+
+ model_name
+ end
+
+ def expose_nested_refs(obj)
+ case obj
+ when Hash
+ ref = obj['$ref'] || obj[:$ref]
+ expose_ref_if_needed(ref) if ref
+ obj.each_value { |v| expose_nested_refs(v) }
+ when Array
+ obj.each { |item| expose_nested_refs(item) }
+ end
+ end
+
+ def expose_ref_if_needed(ref)
+ ref_name = ref.split('/').last
+ return if @definitions.key?(ref_name)
+
+ klass = Object.const_get(ref_name)
+ expose_params_from_model(klass) if GrapeSwagger.model_parsers.find(klass)
+ rescue NameError
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/info.rb b/lib/grape-swagger/openapi/builder/concerns/info.rb
new file mode 100644
index 00000000..3cd6729b
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/info.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI Info object from configuration options
+ module InfoBuilder
+ private
+
+ def build_info
+ info_options = options[:info] || {}
+ @spec.info = OpenAPI::Info.new(
+ title: info_options[:title] || 'API title',
+ description: info_options[:description],
+ terms_of_service: info_options[:terms_of_service_url],
+ version: options[:doc_version] || info_options[:version] || '1.0',
+ contact_name: info_options[:contact_name],
+ contact_email: info_options[:contact_email],
+ contact_url: info_options[:contact_url]
+ )
+
+ build_license(info_options)
+ copy_info_extensions(info_options)
+ end
+
+ def build_license(info_options)
+ license = info_options[:license]
+ return unless license
+
+ if license.is_a?(Hash)
+ @spec.info.license_name = license[:name]
+ @spec.info.license_url = license[:url] || info_options[:license_url]
+ @spec.info.license_identifier = license[:identifier]
+ else
+ @spec.info.license_name = license
+ @spec.info.license_url = info_options[:license_url]
+ end
+ end
+
+ def copy_info_extensions(info_options)
+ info_options.each do |key, value|
+ @spec.info.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/operations.rb b/lib/grape-swagger/openapi/builder/concerns/operations.rb
new file mode 100644
index 00000000..b5a5efa4
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/operations.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI Operation objects from Grape route definitions
+ module OperationBuilder
+ private
+
+ def build_operation(route, path)
+ operation = OpenAPI::Operation.new
+ operation.operation_id = GrapeSwagger::DocMethods::OperationId.build(route, path)
+ operation.summary = build_summary(route)
+ operation.description = build_description(route)
+ operation.deprecated = route.options[:deprecated] if route.options.key?(:deprecated)
+ operation.tags = route.options.fetch(:tags, build_tags_for_route(route, path))
+ operation.security = route.options[:security] if route.options.key?(:security)
+ operation.produces = build_produces(route)
+ operation.consumes = build_consumes(route)
+
+ build_operation_parameters(operation, route, path)
+ build_operation_responses(operation, route)
+ add_operation_extensions(operation, route)
+
+ operation
+ end
+
+ def build_summary(route)
+ summary = route.options[:desc] if route.options.key?(:desc)
+ summary = route.description if route.description.present? && route.options.key?(:detail)
+ summary = route.options[:summary] if route.options.key?(:summary)
+ summary
+ end
+
+ def build_description(route)
+ description = route.description if route.description.present?
+ description = route.options[:detail] if route.options.key?(:detail)
+ description
+ end
+
+ def build_produces(route)
+ return ['application/octet-stream'] if file_response?(route.options[:success]) &&
+ !route.options[:produces].present?
+
+ format = options[:produces] || options[:format]
+ mime_types = GrapeSwagger::DocMethods::ProducesConsumes.call(format)
+
+ route_mime_types = %i[formats content_types produces].filter_map do |producer|
+ possible = route.options[producer]
+ GrapeSwagger::DocMethods::ProducesConsumes.call(possible) if possible.present?
+ end.flatten.uniq
+
+ route_mime_types.presence || mime_types
+ end
+
+ def build_consumes(route)
+ return unless %i[post put patch].include?(route.request_method.downcase.to_sym)
+
+ format = options[:consumes] || options[:format]
+ GrapeSwagger::DocMethods::ProducesConsumes.call(
+ route.settings.dig(:description, :consumes) || format
+ )
+ end
+
+ def build_tags_for_route(route, path)
+ version = GrapeSwagger::DocMethods::Version.get(route)
+ version = Array(version)
+ prefix = route.prefix.to_s.split('/').reject(&:empty?)
+
+ Array(
+ path.split('{')[0].split('/').reject(&:empty?).delete_if do |i|
+ prefix.include?(i) || version.map(&:to_s).include?(i)
+ end.first
+ ).presence
+ end
+
+ def add_operation_extensions(operation, route)
+ x_operation = route.settings[:x_operation]
+ return unless x_operation
+
+ x_operation.each do |key, value|
+ operation.extensions["x-#{key}"] = value
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/param_schemas.rb b/lib/grape-swagger/openapi/builder/concerns/param_schemas.rb
new file mode 100644
index 00000000..37c22d9b
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/param_schemas.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds parameter schemas with type handling for OpenAPI
+ module ParamSchemaBuilder # rubocop:disable Metrics/ModuleLength
+ private
+
+ def apply_additional_properties(schema, additional_props)
+ case additional_props
+ when true, false, Hash
+ schema.additional_properties = additional_props
+ when String
+ schema.additional_properties = { type: additional_props.downcase }
+ when Class
+ apply_additional_properties_class(schema, additional_props)
+ end
+ end
+
+ def apply_additional_properties_class(schema, klass)
+ if has_model_parser?(klass)
+ model_name = expose_params_from_model(klass)
+ schema.additional_properties = { canonical_name: model_name } if model_name
+ else
+ type_name = GrapeSwagger::DocMethods::DataType.call(type: klass)
+ schema.additional_properties = { type: type_name }
+ end
+ end
+
+ def apply_type_to_schema(schema, data_type, param_options)
+ original_type = param_options[:type]
+ element_class = extract_array_element_class(original_type)
+
+ if element_class
+ apply_array_entity_type(schema, element_class, param_options, data_type)
+ elsif data_type == 'array' || param_options[:is_array]
+ apply_array_type(schema, param_options, data_type)
+ elsif GrapeSwagger::DocMethods::DataType.primitive?(data_type)
+ apply_primitive_type(schema, data_type, param_options)
+ elsif data_type == 'file'
+ apply_file_type(schema)
+ elsif %w[json JSON].include?(data_type)
+ schema.type = 'object'
+ elsif @definitions.key?(data_type)
+ schema.canonical_name = data_type
+ else
+ apply_complex_type(schema, data_type, original_type)
+ end
+ end
+
+ def apply_array_entity_type(schema, element_class, param_options, data_type)
+ schema.type = 'array'
+ if has_model_parser?(element_class)
+ model_name = expose_params_from_model(element_class)
+ items = OpenAPI::Schema.new
+ items.canonical_name = model_name if model_name
+ schema.items = items
+ else
+ schema.items = build_array_items_schema(param_options, data_type)
+ end
+ end
+
+ def apply_array_type(schema, param_options, data_type)
+ schema.type = 'array'
+ schema.items = build_array_items_schema(param_options, data_type)
+ end
+
+ def apply_primitive_type(schema, data_type, param_options)
+ type, format = GrapeSwagger::DocMethods::DataType.mapping(data_type)
+ schema.type = type
+ schema.format = param_options[:format] || format
+ end
+
+ def apply_file_type(schema)
+ schema.type = 'string'
+ schema.format = 'binary'
+ end
+
+ def apply_complex_type(schema, data_type, original_type)
+ return if try_apply_class_type(schema, original_type)
+ return if try_apply_string_class_type(schema, original_type)
+
+ schema.type = data_type
+ end
+
+ def try_apply_class_type(schema, original_type)
+ return false unless original_type.is_a?(Class)
+ return false unless has_model_parser?(original_type)
+
+ model_name = expose_params_from_model(original_type)
+ schema.canonical_name = model_name if model_name
+ true
+ end
+
+ def try_apply_string_class_type(schema, original_type)
+ return false unless original_type.is_a?(String)
+ return false if GrapeSwagger::DocMethods::DataType.primitive?(original_type)
+
+ klass = Object.const_get(original_type)
+ return false unless has_model_parser?(klass)
+
+ model_name = expose_params_from_model(klass)
+ schema.canonical_name = model_name if model_name
+ true
+ rescue NameError
+ false
+ end
+
+ def has_model_parser?(klass)
+ GrapeSwagger.model_parsers.find(klass)
+ rescue StandardError
+ false
+ end
+
+ def extract_array_element_class(type)
+ return type.first if type.is_a?(Array) && type.first.is_a?(Class)
+
+ if type.is_a?(String) && type =~ /\A\[(.+)\]\z/
+ class_name = ::Regexp.last_match(1).strip
+ begin
+ return Object.const_get(class_name)
+ rescue NameError
+ return nil
+ end
+ end
+
+ nil
+ end
+
+ def build_array_items_schema(param_options, data_type = nil)
+ items = OpenAPI::Schema.new
+ doc = param_options[:documentation] || {}
+
+ item_type = if doc[:type]
+ GrapeSwagger::DocMethods::DataType.call(type: doc[:type])
+ elsif data_type && data_type != 'array'
+ data_type
+ else
+ 'string'
+ end
+
+ if GrapeSwagger::DocMethods::DataType.primitive?(item_type)
+ type, format = GrapeSwagger::DocMethods::DataType.mapping(item_type)
+ items.type = type
+ items.format = format
+ elsif item_type == 'file'
+ items.type = 'string'
+ items.format = 'binary'
+ elsif @definitions.key?(item_type)
+ items.canonical_name = item_type
+ else
+ items.type = item_type
+ end
+
+ items
+ end
+
+ def apply_constraints_to_schema(schema, param_options)
+ values = param_options[:values]
+ case values
+ when Range
+ schema.minimum = values.begin if values.begin.is_a?(Integer)
+ schema.maximum = values.end if values.end.is_a?(Integer)
+ when Array
+ schema.enum = values
+ when Proc
+ result = values.call if values.parameters.empty?
+ schema.enum = result if result.is_a?(Array)
+ end
+
+ schema.default = param_options[:default] if param_options.key?(:default)
+ schema.min_length = param_options[:min_length] if param_options[:min_length]
+ schema.max_length = param_options[:max_length] if param_options[:max_length]
+
+ doc = param_options[:documentation] || {}
+ schema.description = param_options[:desc] ||
+ param_options[:description] ||
+ doc[:desc] ||
+ doc[:description]
+ end
+
+ def copy_param_extensions(param, param_options)
+ doc = param_options[:documentation] || {}
+
+ doc.fetch(:x, {}).each do |key, value|
+ param.extensions["x-#{key}"] = value
+ end
+
+ param_options.each do |key, value|
+ param.extensions[key.to_s] = value if key.to_s.start_with?('x-')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/parameters.rb b/lib/grape-swagger/openapi/builder/concerns/parameters.rb
new file mode 100644
index 00000000..9262e5f6
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/parameters.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require_relative 'param_schemas'
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI parameters from Grape route parameters
+ module ParameterBuilder
+ include ParamSchemaBuilder
+
+ def build_operation_parameters(operation, route, path)
+ raw_params = build_request_params(route)
+ consumes = operation.consumes || @spec.consumes
+
+ body_params = []
+ form_data_params = []
+
+ raw_params.each do |name, param_options|
+ next if hidden_parameter?(param_options)
+
+ param = build_parameter(name, param_options, route, path, consumes)
+ is_nested = name.to_s.include?('[')
+
+ case param.location
+ when 'body'
+ body_params << { name: name, options: param_options, param: param }
+ when 'formData'
+ if is_nested
+ body_params << { name: name, options: param_options, param: param }
+ else
+ form_data_params << param
+ end
+ else
+ operation.add_parameter(param)
+ end
+ end
+
+ if body_params.any?
+ build_request_body_from_params(operation, body_params, consumes, route, path)
+ elsif form_data_params.any?
+ build_request_body_from_form_data(operation, form_data_params, consumes)
+ end
+ end
+
+ private
+
+ def build_request_params(route)
+ GrapeSwagger.request_param_parsers.each_with_object({}) do |parser_klass, accum|
+ params = parser_klass.parse(route, accum, options, @endpoint)
+ accum.merge!(params.stringify_keys)
+ end
+ end
+
+ def build_parameter(name, param_options, route, path, consumes)
+ param = OpenAPI::Parameter.new
+ param.name = param_options[:full_name] || name
+ param.location = determine_param_location(name, param_options, route, path, consumes)
+ param.description = param_options[:desc] || param_options[:description]
+ param.required = param.location == 'path' || param_options[:required] || false
+ param.schema = build_param_schema(param_options)
+ param.deprecated = param_options[:deprecated] if param_options.key?(:deprecated)
+ copy_param_extensions(param, param_options)
+ param
+ end
+
+ def determine_param_location(name, param_options, route, path, consumes)
+ return 'path' if path.include?("{#{name}}")
+
+ doc = param_options[:documentation] || {}
+ return doc[:param_type] if doc[:param_type]
+ return doc[:in] if doc[:in]
+
+ if %w[POST PUT PATCH].include?(route.request_method)
+ # Normalize consumes to array (can be string like 'multipart/form-data')
+ consumes_array = Array(consumes)
+ consumes_array.any? { |c| c.to_s.include?('form') } ? 'formData' : 'body'
+ else
+ 'query'
+ end
+ end
+
+ def build_param_schema(param_options)
+ schema = OpenAPI::Schema.new
+ data_type = GrapeSwagger::DocMethods::DataType.call(param_options)
+ apply_type_to_schema(schema, data_type, param_options)
+
+ doc = param_options[:documentation] || {}
+ # Support both documentation: { nullable: true } and x: { nullable: true } for backward compatibility
+ schema.nullable = true if param_options[:allow_blank] || doc[:nullable] || doc.dig(:x, :nullable)
+
+ if doc.key?(:additional_properties)
+ target = schema.type == 'array' && schema.items ? schema.items : schema
+ apply_additional_properties(target, doc[:additional_properties])
+ end
+
+ apply_constraints_to_schema(schema, param_options)
+ schema
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/request_body.rb b/lib/grape-swagger/openapi/builder/concerns/request_body.rb
new file mode 100644
index 00000000..6124680d
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/request_body.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI request bodies from Grape route parameters
+ module RequestBodyBuilder # rubocop:disable Metrics/ModuleLength
+ def build_request_body_from_params(operation, body_params, consumes, route, path)
+ request_body = OpenAPI::RequestBody.new
+ request_body.required = body_params.any? { |bp| bp[:options][:required] }
+ request_body.description = route.description
+
+ schema = build_nested_body_schema(body_params, route)
+
+ definition_name = GrapeSwagger::DocMethods::OperationId.build(route, path)
+ @definitions[definition_name] = { type: 'object' }
+ @spec.components.add_schema(definition_name, schema)
+
+ ref_schema = OpenAPI::Schema.new
+ ref_schema.canonical_name = definition_name
+
+ content_types = consumes || ['application/json']
+ content_types.each do |content_type|
+ request_body.add_media_type(content_type, schema: ref_schema)
+ end
+
+ operation.request_body = request_body
+ end
+
+ def build_request_body_from_form_data(operation, form_data_params, consumes)
+ request_body = OpenAPI::RequestBody.new
+ request_body.required = form_data_params.any?(&:required)
+
+ schema = OpenAPI::Schema.new(type: 'object')
+ form_data_params.each do |param|
+ schema.add_property(param.name, param.schema)
+ schema.mark_required(param.name) if param.required
+ end
+
+ has_file = form_data_params.any? { |p| p.schema&.format == 'binary' }
+ default_content_type = has_file ? 'multipart/form-data' : 'application/x-www-form-urlencoded'
+
+ content_types = consumes&.any? ? consumes : [default_content_type]
+ content_types.each do |content_type|
+ request_body.add_media_type(content_type, schema: schema)
+ end
+
+ operation.request_body = request_body
+ end
+
+ private
+
+ def build_nested_body_schema(body_params, route)
+ schema = OpenAPI::Schema.new(type: 'object')
+ schema.description = route.description
+
+ top_level = []
+ nested = []
+ body_params.each do |bp|
+ if bp[:name].to_s.include?('[')
+ nested << bp
+ else
+ top_level << bp
+ end
+ end
+
+ top_level.each do |bp|
+ name = bp[:name].to_s
+ prop_schema = build_param_schema(bp[:options])
+
+ related_nested = nested.select { |n| n[:name].to_s.start_with?("#{name}[") }
+ build_nested_properties(prop_schema, name, related_nested) if related_nested.any?
+
+ schema.add_property(name, prop_schema)
+ schema.mark_required(name) if bp[:options][:required]
+ end
+
+ schema
+ end
+
+ def build_nested_properties(parent_schema, parent_name, nested_params)
+ children = group_nested_params_by_child(parent_name, nested_params)
+
+ children.each do |child_name, child_params|
+ direct_param = child_params.find { |p| p[:name].to_s == "#{parent_name}[#{child_name}]" }
+ next unless direct_param
+
+ child_schema = build_param_schema(direct_param[:options])
+ process_deeper_nested(child_schema, parent_name, child_name, child_params)
+ add_child_to_parent(parent_schema, child_name, child_schema, direct_param[:options][:required])
+ end
+ end
+
+ def group_nested_params_by_child(parent_name, nested_params)
+ nested_params.each_with_object({}) do |np, children|
+ remainder = np[:name].to_s.sub("#{parent_name}[", '')
+ child_name = remainder.include?('][') ? remainder.split('][').first.chomp(']') : remainder.chomp(']')
+ children[child_name] ||= []
+ children[child_name] << np
+ end
+ end
+
+ def process_deeper_nested(child_schema, parent_name, child_name, child_params)
+ deeper_nested = child_params.reject { |p| p[:name].to_s == "#{parent_name}[#{child_name}]" }
+ return unless deeper_nested.any?
+
+ nested_path = "#{parent_name}[#{child_name}]"
+ target_schema = child_schema.type == 'array' && child_schema.items ? child_schema.items : child_schema
+ build_nested_properties(target_schema, nested_path, deeper_nested)
+ end
+
+ def add_child_to_parent(parent_schema, child_name, child_schema, required)
+ if parent_schema.type == 'array' && parent_schema.items
+ add_property_to_array_items(parent_schema.items, child_name, child_schema, required)
+ else
+ add_property_to_object(parent_schema, child_name, child_schema, required)
+ end
+ end
+
+ def add_property_to_array_items(items_schema, child_name, child_schema, required)
+ items_schema.type = 'object'
+ items_schema.format = nil
+ items_schema.add_property(child_name, child_schema)
+ items_schema.mark_required(child_name) if required
+ end
+
+ def add_property_to_object(parent_schema, child_name, child_schema, required)
+ convert_to_object_if_needed(parent_schema)
+ parent_schema.add_property(child_name, child_schema)
+ parent_schema.mark_required(child_name) if required
+ end
+
+ def convert_to_object_if_needed(schema)
+ return unless schema.type && !%w[object array].include?(schema.type)
+
+ schema.type = 'object'
+ schema.format = nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/responses.rb b/lib/grape-swagger/openapi/builder/concerns/responses.rb
new file mode 100644
index 00000000..3c1ebbc6
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/responses.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI responses from Grape route configuration
+ module ResponseBuilder # rubocop:disable Metrics/ModuleLength
+ def build_operation_responses(operation, route)
+ codes = build_response_codes(route)
+
+ codes.each do |code_info|
+ response = build_response(code_info, route)
+ operation.add_response(code_info[:code], response)
+ end
+ end
+
+ private
+
+ def build_response_codes(route)
+ if route.http_codes.is_a?(Array) && route.http_codes.any? { |c| success_code?(c) }
+ route.http_codes.map { |c| normalize_code(c) }
+ else
+ success_codes = build_success_codes(route)
+ default_codes = build_default_codes(route)
+ failure_codes = (route.http_codes || route.options[:failure] || []).map { |c| normalize_code(c) }
+ success_codes + default_codes + failure_codes
+ end
+ end
+
+ def build_default_codes(route)
+ entity = route.options[:default_response]
+ return [] if entity.nil?
+
+ default_code = { code: 'default', message: 'Default Response' }
+ if entity.is_a?(Hash)
+ default_code[:message] = entity[:message] || default_code[:message]
+ default_code[:model] = entity[:model] if entity[:model]
+ else
+ default_code[:model] = entity
+ end
+
+ [default_code]
+ end
+
+ def success_code?(code)
+ status = code.is_a?(Array) ? code.first : code[:code]
+ status.between?(200, 299)
+ end
+
+ def normalize_code(code)
+ if code.is_a?(Array)
+ { code: code[0], message: code[1], model: code[2], examples: code[3], headers: code[4] }
+ else
+ code
+ end
+ end
+
+ def build_success_codes(route)
+ entity = @current_entity
+
+ return entity.map { |e| success_code_from_entity(route, e) } if entity.is_a?(Array)
+
+ [success_code_from_entity(route, entity)]
+ end
+
+ def success_code_from_entity(route, entity)
+ default_code = GrapeSwagger::DocMethods::StatusCodes.get[route.request_method.downcase.to_sym].dup
+
+ if entity.is_a?(Hash)
+ default_code[:code] = entity[:code] if entity[:code]
+ default_code[:model] = entity[:model] if entity[:model]
+ default_code[:headers] = entity[:headers] if entity[:headers]
+ default_code[:is_array] = entity[:is_array] if entity[:is_array]
+ default_code[:message] =
+ entity[:message] || route.description || default_code[:message].sub('{item}', @current_item)
+ elsif entity
+ default_code[:model] = entity
+ default_code[:message] = route.description || default_code[:message].sub('{item}', @current_item)
+ else
+ default_code[:message] = route.description || default_code[:message].sub('{item}', @current_item)
+ end
+
+ if route.request_method == 'DELETE' && default_code[:model].nil? && default_code[:code] == 200
+ default_code[:code] = 204
+ end
+
+ default_code
+ end
+
+ def build_response(code_info, route)
+ response = OpenAPI::Response.new
+ response.status_code = code_info[:code].to_s
+ response.description = code_info[:message] || ''
+
+ add_response_content(response, code_info, route)
+ add_response_headers(response, code_info)
+
+ response
+ end
+
+ def add_response_content(response, code_info, route)
+ return add_file_response_content(response) if file_response?(code_info[:model])
+ return if code_info[:model] == ''
+
+ model_name = resolve_response_model_name(code_info)
+ return unless model_name && @definitions[model_name]
+
+ schema = build_response_schema(model_name, route, code_info)
+ build_produces(route).each { |content_type| response.add_media_type(content_type, schema: schema) }
+ end
+
+ def add_file_response_content(response)
+ schema = OpenAPI::Schema.new(type: 'string', format: 'binary')
+ response.add_media_type('application/octet-stream', schema: schema)
+ end
+
+ def resolve_response_model_name(code_info)
+ if code_info[:model]
+ expose_params_from_model(code_info[:model])
+ elsif @definitions[@current_item]
+ @current_item
+ end
+ end
+
+ def build_response_schema(model_name, route, code_info)
+ schema = OpenAPI::Schema.new
+ schema.canonical_name = model_name
+
+ return OpenAPI::Schema.new(type: 'array', items: schema) if route.options[:is_array] || code_info[:is_array]
+
+ schema
+ end
+
+ def add_response_headers(response, code_info)
+ code_info[:headers]&.each do |name, header_info|
+ response.headers[name] = OpenAPI::Header.new(
+ name: name,
+ description: header_info[:description],
+ type: header_info[:type],
+ format: header_info[:format]
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/schemas.rb b/lib/grape-swagger/openapi/builder/concerns/schemas.rb
new file mode 100644
index 00000000..c833b856
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/schemas.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI::Schema objects from type definitions.
+ class SchemaBuilder
+ PRIMITIVE_MAPPINGS = {
+ 'integer' => { type: 'integer', format: 'int32' },
+ 'long' => { type: 'integer', format: 'int64' },
+ 'float' => { type: 'number', format: 'float' },
+ 'double' => { type: 'number', format: 'double' },
+ 'string' => { type: 'string' },
+ 'byte' => { type: 'string', format: 'byte' },
+ 'binary' => { type: 'string', format: 'binary' },
+ 'boolean' => { type: 'boolean' },
+ 'date' => { type: 'string', format: 'date' },
+ 'dateTime' => { type: 'string', format: 'date-time' },
+ 'password' => { type: 'string', format: 'password' },
+ 'email' => { type: 'string', format: 'email' },
+ 'uuid' => { type: 'string', format: 'uuid' },
+ # JSON type maps to object
+ 'json' => { type: 'object' },
+ # OAS 3.1 supports null as a type
+ 'null' => { type: 'null' }
+ }.freeze
+
+ RUBY_TYPE_MAPPINGS = {
+ 'Integer' => 'integer',
+ 'Fixnum' => 'integer',
+ 'Bignum' => 'integer',
+ 'Float' => 'float',
+ 'BigDecimal' => 'double',
+ 'Numeric' => 'double',
+ 'TrueClass' => 'boolean',
+ 'FalseClass' => 'boolean',
+ 'String' => 'string',
+ 'Symbol' => 'string',
+ 'Date' => 'date',
+ 'DateTime' => 'dateTime',
+ 'Time' => 'dateTime',
+ 'Hash' => 'object',
+ 'JSON' => 'object',
+ 'Array' => 'array',
+ 'Rack::Multipart::UploadedFile' => 'file',
+ 'File' => 'file',
+ # OAS 3.1 supports null as a type
+ 'NilClass' => 'null'
+ }.freeze
+
+ def initialize(definitions = {})
+ @definitions = definitions
+ end
+
+ # Build a schema from a data type string or class
+ def build(type, options = {})
+ schema = OpenAPI::Schema.new
+
+ type_string = normalize_type(type)
+
+ if primitive?(type_string)
+ apply_primitive(schema, type_string, options)
+ elsif type_string == 'array'
+ build_array_schema(schema, options)
+ elsif type_string == 'object'
+ build_object_schema(schema, options)
+ elsif type_string == 'file'
+ schema.type = 'string'
+ schema.format = 'binary'
+ elsif @definitions.key?(type_string)
+ # Reference to a defined model
+ schema.canonical_name = type_string
+ else
+ # Default to string for unknown types
+ schema.type = 'string'
+ end
+
+ apply_common_options(schema, options)
+ schema
+ end
+
+ # Build a schema from a parameter hash (Swagger 2.0 style)
+ def build_from_param(param)
+ schema = OpenAPI::Schema.new
+ apply_type_from_param(schema, param) if param[:type]
+ apply_param_constraints(schema, param)
+ schema
+ end
+
+ def apply_type_from_param(schema, param)
+ type_string = normalize_type(param[:type])
+ apply_typed_schema(schema, type_string, param)
+ end
+
+ def apply_typed_schema(schema, type_string, param)
+ if primitive?(type_string)
+ apply_primitive_from_param(schema, type_string, param)
+ elsif type_string == 'array'
+ apply_array_from_param(schema, param)
+ elsif type_string == 'object'
+ apply_object_from_param(schema, param)
+ elsif type_string == 'file'
+ schema.type = 'string'
+ schema.format = 'binary'
+ elsif @definitions.key?(type_string)
+ schema.canonical_name = type_string
+ else
+ schema.type = type_string
+ end
+ end
+
+ def apply_object_from_param(schema, param)
+ schema.type = 'object'
+ schema.additional_properties = param[:additionalProperties] if param.key?(:additionalProperties)
+ apply_object_properties(schema, param)
+ apply_object_required(schema, param)
+ end
+
+ def apply_object_properties(schema, param)
+ return unless param[:properties]
+
+ param[:properties].each do |name, prop|
+ schema.add_property(name.to_s, build_from_param(prop))
+ end
+ end
+
+ def apply_object_required(schema, param)
+ return unless param[:required].is_a?(Array)
+
+ param[:required].each { |name| schema.mark_required(name.to_s) }
+ end
+
+ def apply_primitive_from_param(schema, type_string, param)
+ mapping = PRIMITIVE_MAPPINGS[type_string] || { type: type_string }
+ schema.type = mapping[:type]
+ schema.format = param[:format] || mapping[:format]
+ end
+
+ def apply_array_from_param(schema, param)
+ schema.type = 'array'
+ schema.items = param[:items] ? build_from_param(param[:items]) : OpenAPI::Schema.new(type: 'string')
+ end
+
+ def apply_param_constraints(schema, param)
+ schema.enum = param[:enum] if param[:enum]
+ schema.default = param[:default] if param.key?(:default)
+ schema.minimum = param[:minimum] if param[:minimum]
+ schema.maximum = param[:maximum] if param[:maximum]
+ schema.min_length = param[:minLength] if param[:minLength]
+ schema.max_length = param[:maxLength] if param[:maxLength]
+ schema.min_items = param[:minItems] if param[:minItems]
+ schema.max_items = param[:maxItems] if param[:maxItems]
+ schema.pattern = param[:pattern] if param[:pattern]
+ schema.description = param[:description] if param[:description]
+ schema.example = param[:example] if param.key?(:example)
+ schema.nullable = param[:nullable] if param[:nullable]
+ end
+
+ # Build schema from a model definition hash
+ def build_from_definition(definition)
+ return build_ref_schema(definition) if definition_is_ref?(definition)
+
+ schema = OpenAPI::Schema.new
+ schema.type = definition[:type] if definition[:type]
+ schema.description = definition[:description] if definition[:description]
+
+ build_definition_properties(schema, definition)
+ build_definition_items(schema, definition)
+ build_definition_composition(schema, definition)
+
+ schema.discriminator = definition[:discriminator] if definition[:discriminator]
+ schema.additional_properties = definition[:additionalProperties] if definition.key?(:additionalProperties)
+
+ schema
+ end
+
+ def definition_is_ref?(definition)
+ definition['$ref'] || definition[:$ref]
+ end
+
+ def build_ref_schema(definition)
+ ref = definition['$ref'] || definition[:$ref]
+ model_name = ref.split('/').last
+ schema = OpenAPI::Schema.new
+ schema.canonical_name = model_name
+ schema
+ end
+
+ def build_definition_properties(schema, definition)
+ definition[:properties]&.each do |name, prop|
+ schema.add_property(name, build_from_param(prop))
+ end
+ definition[:required].each { |name| schema.mark_required(name) } if definition[:required].is_a?(Array)
+ end
+
+ def build_definition_items(schema, definition)
+ schema.items = build_from_definition(definition[:items]) if definition[:items]
+ end
+
+ def build_definition_composition(schema, definition)
+ schema.all_of = definition[:allOf].map { |d| build_from_definition(d) } if definition[:allOf]
+ schema.one_of = definition[:oneOf].map { |d| build_from_definition(d) } if definition[:oneOf]
+ schema.any_of = definition[:anyOf].map { |d| build_from_definition(d) } if definition[:anyOf]
+ end
+
+ private
+
+ def normalize_type(type)
+ return type if type.is_a?(String)
+ return type.name if type.is_a?(Class)
+ return RUBY_TYPE_MAPPINGS[type.to_s] if RUBY_TYPE_MAPPINGS.key?(type.to_s)
+
+ type.to_s
+ end
+
+ def primitive?(type)
+ PRIMITIVE_MAPPINGS.key?(type) || %w[string integer number boolean].include?(type)
+ end
+
+ def apply_primitive(schema, type, options)
+ mapping = PRIMITIVE_MAPPINGS[type] || { type: type }
+ schema.type = mapping[:type]
+ schema.format = options[:format] || mapping[:format]
+ end
+
+ def build_array_schema(schema, options)
+ schema.type = 'array'
+ schema.items = if options[:items]
+ build(options[:items][:type] || 'string', options[:items])
+ else
+ OpenAPI::Schema.new(type: 'string')
+ end
+ end
+
+ def build_object_schema(schema, options)
+ schema.type = 'object'
+ return unless options[:properties]
+
+ options[:properties].each do |name, prop_options|
+ schema.add_property(name, build(prop_options[:type] || 'string', prop_options))
+ end
+ end
+
+ def apply_common_options(schema, options)
+ schema.description = options[:description] if options[:description]
+ schema.enum = options[:enum] if options[:enum]
+ schema.default = options[:default] if options.key?(:default)
+ schema.nullable = options[:nullable] if options.key?(:nullable)
+ schema.example = options[:example] if options.key?(:example)
+ schema.minimum = options[:minimum] if options[:minimum]
+ schema.maximum = options[:maximum] if options[:maximum]
+ schema.min_length = options[:min_length] if options[:min_length]
+ schema.max_length = options[:max_length] if options[:max_length]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/security.rb b/lib/grape-swagger/openapi/builder/concerns/security.rb
new file mode 100644
index 00000000..5ce8d9e5
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/security.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI SecuritySchemes from security_definitions configuration
+ module SecurityBuilder
+ private
+
+ def build_security_definitions
+ return unless options[:security_definitions]
+
+ options[:security_definitions].each do |name, definition|
+ scheme = build_security_scheme(definition)
+ @spec.components.add_security_scheme(name, scheme)
+ end
+
+ @spec.security = options[:security] if options[:security]
+ end
+
+ def build_security_scheme(definition)
+ scheme = OpenAPI::SecurityScheme.new
+ scheme.type = convert_security_type(definition[:type])
+ scheme.description = definition[:description]
+ scheme.name = definition[:name]
+ scheme.location = definition[:in]
+
+ case definition[:type]
+ when 'basic'
+ scheme.type = 'http'
+ scheme.scheme = 'basic'
+ when 'oauth2'
+ scheme.flows = build_oauth_flows(definition)
+ end
+
+ scheme
+ end
+
+ def convert_security_type(type)
+ case type
+ when 'basic' then 'http'
+ else type
+ end
+ end
+
+ def build_oauth_flows(definition)
+ flow_type = case definition[:flow]
+ when 'implicit' then 'implicit'
+ when 'password' then 'password'
+ when 'application' then 'clientCredentials'
+ when 'accessCode' then 'authorizationCode'
+ else definition[:flow]
+ end
+
+ {
+ flow_type => {
+ authorizationUrl: definition[:authorizationUrl],
+ tokenUrl: definition[:tokenUrl],
+ scopes: definition[:scopes]
+ }.compact
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/servers.rb b/lib/grape-swagger/openapi/builder/concerns/servers.rb
new file mode 100644
index 00000000..58e4f36b
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/servers.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI Servers array from host/basePath/schemes configuration
+ module ServerBuilder
+ private
+
+ def build_servers
+ host = GrapeSwagger::DocMethods::OptionalObject.build(:host, options, @request)
+ base_path = GrapeSwagger::DocMethods::OptionalObject.build(:base_path, options, @request)
+ schemes = normalize_schemes(options[:schemes])
+
+ # Store for Swagger 2.0 compatibility
+ @spec.host = host
+ @spec.base_path = base_path
+ @spec.schemes = schemes
+
+ # Build OAS3 servers
+ return unless host
+
+ (schemes.presence || ['https']).each do |scheme|
+ @spec.add_server(
+ OpenAPI::Server.from_swagger2(host: host, base_path: base_path, scheme: scheme)
+ )
+ end
+ end
+
+ def normalize_schemes(schemes)
+ return [] unless schemes
+
+ schemes.is_a?(String) ? [schemes] : Array(schemes)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/concerns/tags.rb b/lib/grape-swagger/openapi/builder/concerns/tags.rb
new file mode 100644
index 00000000..8a8eec3a
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/concerns/tags.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI Tag objects from operations and user configuration
+ module TagBuilder
+ private
+
+ def build_tags
+ all_tags = @spec.paths.each_value.flat_map do |path_item|
+ path_item.operations.flat_map { |_method, operation| operation&.tags || [] }
+ end
+
+ all_tags.uniq.each do |tag_name|
+ tag = OpenAPI::Tag.new(
+ name: tag_name,
+ description: "Operations about #{tag_name.to_s.pluralize}"
+ )
+ @spec.add_tag(tag)
+ end
+
+ # Merge with user-provided tags
+ return unless options[:tags]
+
+ user_tag_names = options[:tags].map { |t| t[:name] }
+ @spec.tags.reject! { |t| user_tag_names.include?(t.name) }
+
+ options[:tags].each do |tag_hash|
+ tag = OpenAPI::Tag.new(
+ name: tag_hash[:name],
+ description: tag_hash[:description]
+ )
+ @spec.add_tag(tag)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/from_hash.rb b/lib/grape-swagger/openapi/builder/from_hash.rb
new file mode 100644
index 00000000..6188f194
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/from_hash.rb
@@ -0,0 +1,367 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI::Document from Swagger 2.0 hash.
+ # This allows converting existing Swagger output to the unified API model.
+ class FromHash
+ attr_reader :spec, :definitions
+
+ def initialize(options = {})
+ @options = options
+ @definitions = {}
+ @spec = OpenAPI::Document.new
+ @schema_builder = SchemaBuilder.new(@definitions)
+ @operation_builder = OperationBuilder.new(@schema_builder, @definitions)
+ end
+
+ # Build the complete spec from swagger output hash
+ # This allows gradual migration - we can build from existing swagger hash
+ def build_from_swagger_hash(swagger_hash)
+ build_info(swagger_hash[:info])
+ build_host_and_servers(swagger_hash)
+ build_content_types(swagger_hash)
+ build_paths(swagger_hash[:paths])
+ build_definitions(swagger_hash[:definitions])
+ build_security(swagger_hash)
+ build_tags(swagger_hash[:tags])
+ build_extensions(swagger_hash)
+
+ @spec
+ end
+
+ private
+
+ def build_info(info_hash)
+ return unless info_hash
+
+ @spec.info = OpenAPI::Info.new(
+ title: info_hash[:title],
+ description: info_hash[:description],
+ terms_of_service: info_hash[:termsOfService],
+ version: info_hash[:version],
+ contact_name: info_hash.dig(:contact, :name),
+ contact_email: info_hash.dig(:contact, :email),
+ contact_url: info_hash.dig(:contact, :url),
+ license_name: info_hash.dig(:license, :name),
+ license_url: info_hash.dig(:license, :url),
+ license_identifier: info_hash.dig(:license, :identifier)
+ )
+
+ # Copy extensions
+ info_hash.each do |key, value|
+ @spec.info.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+ end
+
+ def build_host_and_servers(swagger_hash)
+ @spec.host = swagger_hash[:host]
+ @spec.base_path = swagger_hash[:basePath]
+ @spec.schemes = Array(swagger_hash[:schemes]) if swagger_hash[:schemes]
+
+ # Build servers for OAS3
+ return unless swagger_hash[:host]
+
+ schemes = swagger_hash[:schemes] || ['https']
+ schemes.each do |scheme|
+ @spec.add_server(
+ OpenAPI::Server.from_swagger2(
+ host: swagger_hash[:host],
+ base_path: swagger_hash[:basePath],
+ scheme: scheme
+ )
+ )
+ end
+ end
+
+ def build_content_types(swagger_hash)
+ @spec.produces = swagger_hash[:produces] if swagger_hash[:produces]
+ @spec.consumes = swagger_hash[:consumes] if swagger_hash[:consumes]
+ end
+
+ def build_paths(paths_hash)
+ return unless paths_hash
+
+ paths_hash.each do |path, methods|
+ path_item = OpenAPI::PathItem.new(path: path)
+
+ methods.each do |method, operation_hash|
+ next unless operation_hash.is_a?(Hash)
+
+ operation = build_operation(method, operation_hash)
+ path_item.add_operation(method, operation)
+ end
+
+ @spec.add_path(path, path_item)
+ end
+ end
+
+ def build_operation(_method, operation_hash)
+ operation = OpenAPI::Operation.new
+ set_operation_basics(operation, operation_hash)
+ build_operation_parameters(operation, operation_hash)
+ build_operation_responses(operation, operation_hash)
+ copy_operation_extensions(operation, operation_hash)
+ operation
+ end
+
+ def set_operation_basics(operation, operation_hash)
+ operation.operation_id = operation_hash[:operationId]
+ operation.summary = operation_hash[:summary]
+ operation.description = operation_hash[:description]
+ operation.deprecated = operation_hash[:deprecated] if operation_hash.key?(:deprecated)
+ operation.tags = operation_hash[:tags] if operation_hash[:tags]
+ operation.produces = operation_hash[:produces] if operation_hash[:produces]
+ operation.consumes = operation_hash[:consumes] if operation_hash[:consumes]
+ operation.security = operation_hash[:security] if operation_hash[:security]
+ end
+
+ def build_operation_parameters(operation, operation_hash)
+ return unless operation_hash[:parameters]
+
+ form_data_params = []
+ operation_hash[:parameters].each do |param_hash|
+ param = build_parameter(param_hash)
+ form_data_params = process_param(operation, param, operation_hash, form_data_params)
+ end
+ build_form_data_request_body(operation, form_data_params, operation_hash) if form_data_params.any?
+ end
+
+ def process_param(operation, param, operation_hash, form_data_params)
+ case param.location
+ when 'body'
+ build_request_body_from_param(operation, param, operation_hash[:consumes] || @spec.consumes)
+ when 'formData'
+ form_data_params << param
+ else
+ operation.add_parameter(param)
+ end
+ form_data_params
+ end
+
+ def build_form_data_request_body(operation, form_data_params, operation_hash)
+ build_request_body_from_form_data(operation, form_data_params, operation_hash[:consumes] || @spec.consumes)
+ end
+
+ def build_operation_responses(operation, operation_hash)
+ return unless operation_hash[:responses]
+
+ produces = operation_hash[:produces] || @spec.produces || ['application/json']
+ operation_hash[:responses].each do |code, response_hash|
+ response = build_response(code, response_hash, produces)
+ operation.add_response(code, response)
+ end
+ end
+
+ def copy_operation_extensions(operation, operation_hash)
+ operation_hash.each do |key, value|
+ operation.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+ end
+
+ def build_parameter(param_hash)
+ param = OpenAPI::Parameter.new
+
+ param.name = param_hash[:name]
+ param.location = param_hash[:in]
+ param.description = param_hash[:description]
+ param.required = param_hash[:required]
+
+ # Inline type properties (Swagger 2.0)
+ param.type = param_hash[:type]
+ param.format = param_hash[:format]
+ param.items = param_hash[:items]
+ param.collection_format = param_hash[:collectionFormat]
+ param.default = param_hash[:default]
+ param.enum = param_hash[:enum]
+ param.minimum = param_hash[:minimum]
+ param.maximum = param_hash[:maximum]
+ param.min_length = param_hash[:minLength]
+ param.max_length = param_hash[:maxLength]
+ param.pattern = param_hash[:pattern]
+
+ # Build schema for OAS3
+ param.schema = if param_hash[:schema]
+ @schema_builder.build_from_definition(param_hash[:schema])
+ else
+ @schema_builder.build_from_param(param_hash)
+ end
+
+ # Copy extensions
+ param_hash.each do |key, value|
+ param.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+
+ param
+ end
+
+ def build_request_body_from_param(operation, body_param, consumes)
+ request_body = OpenAPI::RequestBody.new
+ request_body.required = body_param.required
+ request_body.description = body_param.description
+
+ content_types = consumes || ['application/json']
+ content_types.each do |content_type|
+ schema = body_param.schema || @schema_builder.build_from_param(body_param.to_swagger2_h)
+ request_body.add_media_type(content_type, schema: schema)
+ end
+
+ operation.request_body = request_body
+ end
+
+ def build_request_body_from_form_data(operation, form_data_params, consumes)
+ request_body = OpenAPI::RequestBody.new
+
+ # Check if any param is required
+ request_body.required = form_data_params.any?(&:required)
+
+ # Build schema with all form data params as properties
+ schema = OpenAPI::Schema.new(type: 'object')
+ form_data_params.each do |param|
+ prop_schema = param.schema || @schema_builder.build_from_param(param.to_swagger2_h)
+ schema.add_property(param.name, prop_schema)
+ schema.mark_required(param.name) if param.required
+ end
+
+ # Determine content type - use multipart if file present, otherwise form-urlencoded
+ has_file = form_data_params.any? { |p| p.schema&.format == 'binary' || p.type == 'file' }
+ default_content_type = has_file ? 'multipart/form-data' : 'application/x-www-form-urlencoded'
+
+ content_types = consumes&.any? ? consumes : [default_content_type]
+ content_types.each do |content_type|
+ request_body.add_media_type(content_type, schema: schema)
+ end
+
+ operation.request_body = request_body
+ end
+
+ def build_response(code, response_hash, produces)
+ response = OpenAPI::Response.new
+ response.status_code = code.to_s
+ response.description = response_hash[:description] || ''
+ build_response_schema(response, response_hash, produces)
+ build_response_headers(response, response_hash)
+ response.examples = response_hash[:examples] if response_hash[:examples]
+ copy_response_extensions(response, response_hash)
+ response
+ end
+
+ def build_response_schema(response, response_hash, produces)
+ return unless response_hash[:schema]
+
+ schema = @schema_builder.build_from_definition(response_hash[:schema])
+ response.schema = schema
+ add_response_media_types(response, schema, produces)
+ end
+
+ def add_response_media_types(response, schema, produces)
+ if schema.type == 'string' && schema.format == 'binary'
+ response.add_media_type('application/octet-stream', schema: schema)
+ else
+ produces.each { |content_type| response.add_media_type(content_type, schema: schema) }
+ end
+ end
+
+ def build_response_headers(response, response_hash)
+ response_hash[:headers]&.each do |name, header_hash|
+ header = OpenAPI::Header.new(
+ name: name, description: header_hash[:description],
+ type: header_hash[:type], format: header_hash[:format]
+ )
+ header.schema = @schema_builder.build_from_param(header_hash)
+ response.headers[name] = header
+ end
+ end
+
+ def copy_response_extensions(response, response_hash)
+ response_hash.each { |key, value| response.extensions[key] = value if key.to_s.start_with?('x-') }
+ end
+
+ def build_definitions(definitions_hash)
+ return unless definitions_hash
+
+ definitions_hash.each do |name, definition|
+ @definitions[name] = definition
+ schema = @schema_builder.build_from_definition(definition)
+ schema.canonical_name = name
+ @spec.components.add_schema(name, schema)
+ end
+ end
+
+ def build_security(swagger_hash)
+ swagger_hash[:securityDefinitions]&.each do |name, definition|
+ scheme = build_security_scheme(definition)
+ @spec.components.add_security_scheme(name, scheme)
+ end
+
+ @spec.security = swagger_hash[:security] if swagger_hash[:security]
+ end
+
+ def build_security_scheme(definition)
+ scheme = OpenAPI::SecurityScheme.new
+
+ scheme.type = convert_security_type(definition[:type])
+ scheme.description = definition[:description]
+ scheme.name = definition[:name]
+ scheme.location = definition[:in]
+
+ case definition[:type]
+ when 'basic'
+ scheme.type = 'http'
+ scheme.scheme = 'basic'
+ when 'oauth2'
+ scheme.flows = build_oauth_flows(definition)
+ end
+
+ scheme
+ end
+
+ def convert_security_type(type)
+ case type
+ when 'basic' then 'http'
+ when 'apiKey' then 'apiKey'
+ when 'oauth2' then 'oauth2'
+ else type
+ end
+ end
+
+ def build_oauth_flows(definition)
+ flow_type = case definition[:flow]
+ when 'implicit' then 'implicit'
+ when 'password' then 'password'
+ when 'application' then 'clientCredentials'
+ when 'accessCode' then 'authorizationCode'
+ else definition[:flow]
+ end
+
+ {
+ flow_type => {
+ authorizationUrl: definition[:authorizationUrl],
+ tokenUrl: definition[:tokenUrl],
+ scopes: definition[:scopes]
+ }.compact
+ }
+ end
+
+ def build_tags(tags_array)
+ return unless tags_array
+
+ tags_array.each do |tag_hash|
+ tag = OpenAPI::Tag.new(
+ name: tag_hash[:name],
+ description: tag_hash[:description]
+ )
+ @spec.add_tag(tag)
+ end
+ end
+
+ def build_extensions(swagger_hash)
+ swagger_hash.each do |key, value|
+ @spec.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/builder/operation_builder.rb b/lib/grape-swagger/openapi/builder/operation_builder.rb
new file mode 100644
index 00000000..58451ad8
--- /dev/null
+++ b/lib/grape-swagger/openapi/builder/operation_builder.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ module Builder
+ # Builds OpenAPI::Operation objects from Grape route definitions.
+ class OperationBuilder
+ def initialize(schema_builder, definitions = {})
+ @schema_builder = schema_builder
+ @parameter_builder = ParameterBuilder.new(schema_builder)
+ @response_builder = ResponseBuilder.new(schema_builder, definitions)
+ @definitions = definitions
+ end
+
+ # Build an operation from route and parsed parameters/responses
+ # rubocop:disable Lint/UnusedMethodArgument
+ def build(method:, params:, responses:, route_options:, content_types: {})
+ # rubocop:enable Lint/UnusedMethodArgument
+ operation = OpenAPI::Operation.new
+
+ # Basic info
+ operation.operation_id = route_options[:operation_id]
+ operation.summary = route_options[:summary]
+ operation.description = route_options[:description]
+ operation.deprecated = route_options[:deprecated] if route_options.key?(:deprecated)
+ operation.tags = Array(route_options[:tags]) if route_options[:tags]
+
+ # Content types (Swagger 2.0 style)
+ operation.produces = content_types[:produces] if content_types[:produces]
+ operation.consumes = content_types[:consumes] if content_types[:consumes]
+
+ # Security
+ operation.security = route_options[:security] if route_options[:security]
+
+ # Build parameters
+ build_parameters(operation, params, content_types[:consumes])
+
+ # Build responses
+ build_responses(operation, responses, content_types[:produces])
+
+ # Copy extension fields
+ route_options.each do |key, value|
+ operation.extensions[key] = value if key.to_s.start_with?('x-')
+ end
+
+ operation
+ end
+
+ private
+
+ def build_parameters(operation, params, consumes)
+ return unless params&.any?
+
+ params.each do |param_hash|
+ param = @parameter_builder.build(param_hash)
+
+ if param.location == 'body'
+ # Body params become request body in OAS3
+ build_request_body(operation, param, consumes)
+ elsif param.location == 'formData'
+ # Form data also becomes request body in OAS3
+ add_form_param_to_request_body(operation, param, consumes)
+ else
+ operation.add_parameter(param)
+ end
+ end
+ end
+
+ def build_request_body(operation, body_param, consumes)
+ request_body = operation.request_body || OpenAPI::RequestBody.new
+ request_body.required = body_param.required
+ request_body.description = body_param.description
+
+ content_types = consumes || ['application/json']
+ content_types.each do |content_type|
+ request_body.add_media_type(content_type, schema: body_param.schema)
+ end
+
+ operation.request_body = request_body
+ end
+
+ def add_form_param_to_request_body(operation, form_param, _consumes)
+ request_body = operation.request_body || OpenAPI::RequestBody.new
+
+ # Determine content type for form data
+ has_file = form_param.type == 'file' ||
+ (form_param.schema && form_param.schema.type == 'string' && form_param.schema.format == 'binary')
+ content_type = has_file ? 'multipart/form-data' : 'application/x-www-form-urlencoded'
+
+ # Get or create the form schema
+ form_media_type = request_body.media_types.find { |mt| mt.mime_type == content_type }
+
+ if form_media_type
+ # Add property to existing schema
+ form_schema = form_media_type.schema
+ else
+ form_schema = OpenAPI::Schema.new(type: 'object')
+ request_body.add_media_type(content_type, schema: form_schema)
+ end
+
+ # Add the parameter as a property
+ prop_schema = form_param.schema || @schema_builder.build_from_param(
+ type: form_param.type,
+ format: form_param.format
+ )
+
+ # Convert file type for OAS3
+ prop_schema = OpenAPI::Schema.new(type: 'string', format: 'binary') if form_param.type == 'file'
+
+ form_schema.add_property(form_param.name, prop_schema)
+ form_schema.mark_required(form_param.name) if form_param.required
+
+ operation.request_body = request_body
+ end
+
+ def build_responses(operation, responses, produces)
+ return unless responses
+
+ content_types = produces || ['application/json']
+
+ responses.each do |code, response_hash|
+ response = @response_builder.build(code, response_hash, content_types: content_types)
+ operation.add_response(code, response)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/components.rb b/lib/grape-swagger/openapi/components.rb
new file mode 100644
index 00000000..871a0906
--- /dev/null
+++ b/lib/grape-swagger/openapi/components.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Components container (OAS3) / Definitions container (Swagger 2.0).
+ class Components
+ attr_accessor :schemas, :responses, :parameters, :examples,
+ :request_bodies, :headers, :security_schemes,
+ :links, :callbacks, :extensions
+
+ def initialize
+ @schemas = {}
+ @responses = {}
+ @parameters = {}
+ @examples = {}
+ @request_bodies = {}
+ @headers = {}
+ @security_schemes = {}
+ @links = {}
+ @callbacks = {}
+ @extensions = {}
+ end
+
+ def add_schema(name, schema)
+ @schemas[name] = schema
+ end
+
+ def add_security_scheme(name, scheme)
+ @security_schemes[name] = scheme
+ end
+
+ def empty?
+ schemas.empty? && responses.empty? && parameters.empty? &&
+ examples.empty? && request_bodies.empty? && headers.empty? &&
+ security_schemes.empty? && links.empty? && callbacks.empty?
+ end
+
+ def to_h
+ hash = {}
+ add_schema_components(hash)
+ add_other_components(hash)
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Swagger 2.0 style - definitions and securityDefinitions are separate
+ def definitions_h
+ schemas.transform_values { |s| s.respond_to?(:to_h) ? s.to_h : s }
+ end
+
+ def security_definitions_h
+ security_schemes.transform_values(&:to_swagger2_h)
+ end
+
+ private
+
+ def add_schema_components(hash)
+ hash[:schemas] = schemas.transform_values { |s| s.respond_to?(:to_h) ? s.to_h : s } if schemas.any?
+ hash[:responses] = responses.transform_values(&:to_h) if responses.any?
+ hash[:parameters] = parameters.transform_values(&:to_h) if parameters.any?
+ hash[:examples] = examples if examples.any?
+ hash[:requestBodies] = request_bodies.transform_values(&:to_h) if request_bodies.any?
+ end
+
+ def add_other_components(hash)
+ hash[:headers] = headers.transform_values(&:to_h) if headers.any?
+ hash[:securitySchemes] = security_schemes.transform_values(&:to_h) if security_schemes.any?
+ hash[:links] = links if links.any?
+ hash[:callbacks] = callbacks if callbacks.any?
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/document.rb b/lib/grape-swagger/openapi/document.rb
new file mode 100644
index 00000000..d334ff3c
--- /dev/null
+++ b/lib/grape-swagger/openapi/document.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Root specification container - version agnostic.
+ class Document
+ attr_accessor :info, :servers, :paths, :components,
+ :security, :tags, :external_docs,
+ :extensions,
+ # OpenAPI 3.1 specific
+ :webhooks, :json_schema_dialect,
+ # Swagger 2.0 specific
+ :host, :base_path, :schemes,
+ :produces, :consumes
+
+ def initialize
+ @info = Info.new
+ @servers = []
+ @paths = {}
+ @webhooks = {}
+ @components = Components.new
+ @security = []
+ @tags = []
+ @extensions = {}
+ @schemes = []
+ end
+
+ def add_webhook(name, path_item)
+ @webhooks[name] = path_item
+ end
+
+ def add_path(path_string, path_item)
+ @paths[path_string] = path_item
+ end
+
+ def add_tag(tag)
+ @tags << tag unless @tags.any? { |t| t.name == tag.name }
+ end
+
+ def add_server(server)
+ @servers << server
+ end
+
+ # OpenAPI 3.x output
+ def to_h(version: '3.0')
+ hash = { openapi: version_string(version) }
+ hash[:info] = info.to_h
+ hash[:servers] = servers_for_oas3.map(&:to_h) if servers_for_oas3.any?
+ hash[:tags] = tags.map(&:to_h) if tags.any?
+ hash[:paths] = paths.transform_values(&:to_h) if paths.any?
+ hash[:components] = components.to_h unless components.empty?
+ hash[:security] = security if security.any?
+ hash[:externalDocs] = external_docs.to_h if external_docs
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Swagger 2.0 output
+ def to_swagger2_h
+ hash = { swagger: '2.0', info: swagger2_info }
+ add_swagger2_server_fields(hash)
+ add_swagger2_content_types(hash)
+ add_swagger2_main_sections(hash)
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash.compact
+ end
+
+ def add_swagger2_server_fields(hash)
+ hash[:host] = host if host
+ hash[:basePath] = base_path if base_path
+ hash[:schemes] = schemes if schemes.any?
+ end
+
+ def add_swagger2_content_types(hash)
+ hash[:produces] = produces if produces&.any?
+ hash[:consumes] = consumes if consumes&.any?
+ end
+
+ def add_swagger2_main_sections(hash)
+ hash[:tags] = tags.map(&:to_h) if tags.any?
+ hash[:paths] = paths.transform_values(&:to_swagger2_h) if paths.any?
+ hash[:definitions] = components.definitions_h if components.schemas.any?
+ hash[:securityDefinitions] = components.security_definitions_h if components.security_schemes.any?
+ hash[:security] = security if security.any?
+ hash[:externalDocs] = external_docs.to_h if external_docs
+ end
+
+ VERSION_MAPPINGS = {
+ '3.0' => '3.0.3', '3.0.0' => '3.0.3', '3.0.3' => '3.0.3',
+ '3.1' => '3.1.0', '3.1.0' => '3.1.0'
+ }.freeze
+
+ private
+
+ def version_string(version)
+ VERSION_MAPPINGS[version.to_s] || '3.0.3'
+ end
+
+ def servers_for_oas3
+ return servers if servers.any?
+ return [] unless host
+
+ # Build servers from Swagger 2.0 host/basePath/schemes
+ (schemes.presence || ['https']).map do |scheme|
+ Server.from_swagger2(host: host, base_path: base_path, scheme: scheme)
+ end
+ end
+
+ def swagger2_info
+ # Remove license identifier for Swagger 2.0
+ info_hash = info.to_h
+ if info_hash[:license].is_a?(Hash)
+ info_hash[:license] = info_hash[:license].except(:identifier)
+ info_hash[:license] = nil if info_hash[:license].empty?
+ end
+ info_hash.compact
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/info.rb b/lib/grape-swagger/openapi/info.rb
new file mode 100644
index 00000000..b0ce13e6
--- /dev/null
+++ b/lib/grape-swagger/openapi/info.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # API metadata information.
+ class Info
+ attr_accessor :title, :description, :terms_of_service, :version,
+ :contact_name, :contact_email, :contact_url,
+ :license_name, :license_url, :license_identifier,
+ :extensions
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @extensions ||= {}
+ end
+
+ def contact
+ return nil unless contact_name || contact_email || contact_url
+
+ {
+ name: contact_name,
+ email: contact_email,
+ url: contact_url
+ }.compact
+ end
+
+ def license
+ return nil unless license_name || license_url || license_identifier
+
+ {
+ name: license_name,
+ url: license_url,
+ identifier: license_identifier
+ }.compact
+ end
+
+ def to_h
+ hash = {
+ title: title || 'API title',
+ version: version || '1.0'
+ }
+ hash[:description] = description if description
+ hash[:termsOfService] = terms_of_service if terms_of_service
+ hash[:contact] = contact if contact
+ hash[:license] = license if license
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/media_type.rb b/lib/grape-swagger/openapi/media_type.rb
new file mode 100644
index 00000000..7a708864
--- /dev/null
+++ b/lib/grape-swagger/openapi/media_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Media type object wrapping a schema with content-type.
+ # Used in requestBody and responses for OAS3.
+ class MediaType
+ attr_accessor :mime_type, :schema, :example, :examples, :encoding, :extensions
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @mime_type ||= 'application/json'
+ @extensions ||= {}
+ end
+
+ def to_h
+ hash = {}
+ hash[:schema] = schema.respond_to?(:to_h) ? schema.to_h : schema if schema
+ hash[:example] = example unless example.nil?
+ hash[:examples] = examples if examples&.any?
+ hash[:encoding] = encoding if encoding&.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/operation.rb b/lib/grape-swagger/openapi/operation.rb
new file mode 100644
index 00000000..e3d8afb0
--- /dev/null
+++ b/lib/grape-swagger/openapi/operation.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # HTTP operation (GET, POST, etc.) definition.
+ class Operation
+ attr_accessor :operation_id, :summary, :description,
+ :tags, :external_docs,
+ :parameters, :request_body,
+ :responses, :callbacks,
+ :security, :servers,
+ :deprecated,
+ :extensions,
+ # Swagger 2.0 specific
+ :produces, :consumes
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @tags ||= []
+ @parameters ||= []
+ @responses ||= {}
+ @extensions ||= {}
+ end
+
+ def add_parameter(param)
+ @parameters << param
+ end
+
+ def add_response(status_code, response)
+ @responses[status_code.to_s] = response
+ end
+
+ def to_h
+ hash = {}
+ add_operation_basics(hash)
+ add_oas3_fields(hash)
+ add_operation_common(hash)
+ hash
+ end
+
+ # Swagger 2.0 style output
+ def to_swagger2_h
+ hash = {}
+ add_operation_basics(hash)
+ add_swagger2_content_types(hash)
+ add_swagger2_params(hash)
+ add_swagger2_responses(hash)
+ add_operation_common(hash)
+ hash
+ end
+
+ private
+
+ def add_operation_basics(hash)
+ hash[:operationId] = operation_id if operation_id
+ hash[:summary] = summary if summary
+ hash[:description] = description if description
+ hash[:tags] = tags if tags.any?
+ end
+
+ def add_oas3_fields(hash)
+ hash[:externalDocs] = external_docs if external_docs
+ hash[:parameters] = parameters.map(&:to_h) if parameters.any?
+ hash[:requestBody] = request_body.to_h if request_body
+ hash[:responses] = responses.transform_values(&:to_h) if responses.any?
+ hash[:callbacks] = callbacks if callbacks&.any?
+ hash[:servers] = servers.map(&:to_h) if servers&.any?
+ end
+
+ def add_operation_common(hash)
+ hash[:deprecated] = deprecated if deprecated
+ hash[:security] = security if security&.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ end
+
+ def add_swagger2_content_types(hash)
+ hash[:produces] = produces if produces&.any?
+ hash[:consumes] = consumes if consumes&.any?
+ end
+
+ def add_swagger2_params(hash)
+ all_params = parameters.map(&:to_swagger2_h)
+ all_params << request_body.to_swagger2_parameter if request_body
+ hash[:parameters] = all_params.compact if all_params.any?
+ end
+
+ def add_swagger2_responses(hash)
+ hash[:responses] = responses.transform_values(&:to_swagger2_h) if responses.any?
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/parameter.rb b/lib/grape-swagger/openapi/parameter.rb
new file mode 100644
index 00000000..bef481fc
--- /dev/null
+++ b/lib/grape-swagger/openapi/parameter.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Parameter definition for query, path, header, or cookie parameters.
+ # Note: body parameters in OAS2 become RequestBody in OAS3.
+ class Parameter
+ LOCATIONS = %w[query path header cookie].freeze
+
+ attr_accessor :name, :location, :description,
+ :deprecated, :allow_empty_value,
+ :schema, :style, :explode, :allow_reserved,
+ :example, :examples,
+ :extensions,
+ # Swagger 2.0 specific (for backward compat)
+ :type, :format, :items, :collection_format,
+ :default, :enum, :minimum, :maximum,
+ :min_length, :max_length, :pattern
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @extensions ||= {}
+ end
+
+ def path?
+ location == 'path'
+ end
+
+ def query?
+ location == 'query'
+ end
+
+ def header?
+ location == 'header'
+ end
+
+ def cookie?
+ location == 'cookie'
+ end
+
+ # Set required value
+ attr_writer :required
+
+ # Ensure path parameters are always required
+ def required
+ path? || @required
+ end
+
+ # Convert Swagger 2.0 collectionFormat to OAS3 style/explode
+ COLLECTION_FORMAT_STYLES = {
+ 'csv' => 'form',
+ 'ssv' => 'spaceDelimited',
+ 'tsv' => 'pipeDelimited',
+ 'pipes' => 'pipeDelimited',
+ 'multi' => 'form'
+ }.freeze
+
+ def style_from_collection_format
+ COLLECTION_FORMAT_STYLES[collection_format]
+ end
+
+ def explode_from_collection_format
+ collection_format == 'multi'
+ end
+
+ # Build schema from Swagger 2.0 inline properties
+ def build_schema_from_inline
+ return schema if schema
+
+ Schema.new(
+ type: type,
+ format: format,
+ items: items,
+ default: default,
+ enum: enum,
+ minimum: minimum,
+ maximum: maximum,
+ min_length: min_length,
+ max_length: max_length,
+ pattern: pattern
+ )
+ end
+
+ def to_h
+ hash = {
+ name: name,
+ in: location,
+ required: required
+ }
+ hash[:description] = description if description
+ hash[:deprecated] = deprecated if deprecated
+ hash[:allowEmptyValue] = allow_empty_value if allow_empty_value
+ hash[:example] = example unless example.nil?
+ hash[:examples] = examples if examples&.any?
+
+ # Schema (OAS3 style)
+ if schema
+ hash[:schema] = schema.respond_to?(:to_h) ? schema.to_h : schema
+ end
+
+ # Style and explode (OAS3)
+ hash[:style] = style if style
+ hash[:explode] = explode unless explode.nil?
+ hash[:allowReserved] = allow_reserved if allow_reserved
+
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+
+ hash
+ end
+
+ # Swagger 2.0 style output
+ def to_swagger2_h
+ hash = { name: name, in: location, required: required }
+ hash[:description] = description if description
+ add_swagger2_type_fields(hash)
+ add_swagger2_constraints(hash)
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ private
+
+ def add_swagger2_type_fields(hash)
+ hash[:type] = type if type
+ hash[:format] = format if format
+ hash[:items] = items.respond_to?(:to_h) ? items.to_h : items if items
+ hash[:collectionFormat] = collection_format if collection_format
+ end
+
+ def add_swagger2_constraints(hash)
+ hash[:default] = default unless default.nil?
+ hash[:enum] = enum if enum&.any?
+ hash[:minimum] = minimum if minimum
+ hash[:maximum] = maximum if maximum
+ hash[:minLength] = min_length if min_length
+ hash[:maxLength] = max_length if max_length
+ hash[:pattern] = pattern if pattern
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/path_item.rb b/lib/grape-swagger/openapi/path_item.rb
new file mode 100644
index 00000000..f9d2a622
--- /dev/null
+++ b/lib/grape-swagger/openapi/path_item.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Path item containing operations for a specific path.
+ class PathItem
+ HTTP_METHODS = %w[get put post delete options head patch trace].freeze
+
+ attr_accessor :path, :summary, :description, :servers,
+ :parameters, :extensions,
+ :get, :put, :post, :delete, :options, :head, :patch, :trace
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @parameters ||= []
+ @extensions ||= {}
+ end
+
+ def operations
+ HTTP_METHODS.filter_map { |method| [method, public_send(method)] if public_send(method) }
+ end
+
+ def add_operation(method, operation)
+ public_send("#{method.downcase}=", operation)
+ end
+
+ def to_h
+ hash = {}
+ hash[:summary] = summary if summary
+ hash[:description] = description if description
+ hash[:servers] = servers.map(&:to_h) if servers&.any?
+ hash[:parameters] = parameters.map(&:to_h) if parameters.any?
+
+ HTTP_METHODS.each do |method|
+ operation = public_send(method)
+ hash[method.to_sym] = operation.to_h if operation
+ end
+
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Swagger 2.0 style output
+ def to_swagger2_h
+ hash = {}
+ hash[:parameters] = parameters.map(&:to_swagger2_h) if parameters.any?
+
+ HTTP_METHODS.each do |method|
+ operation = public_send(method)
+ hash[method.to_sym] = operation.to_swagger2_h if operation
+ end
+
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/request_body.rb b/lib/grape-swagger/openapi/request_body.rb
new file mode 100644
index 00000000..8b2ebde5
--- /dev/null
+++ b/lib/grape-swagger/openapi/request_body.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Request body definition for OAS3.
+ # In Swagger 2.0, this is converted to a body parameter.
+ class RequestBody
+ attr_accessor :description, :required, :media_types, :extensions
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @media_types ||= []
+ @extensions ||= {}
+ end
+
+ def add_media_type(mime_type, schema:, example: nil, examples: nil)
+ @media_types << MediaType.new(
+ mime_type: mime_type,
+ schema: schema,
+ example: example,
+ examples: examples
+ )
+ end
+
+ def content
+ media_types.each_with_object({}) do |mt, hash|
+ hash[mt.mime_type] = mt.to_h
+ end
+ end
+
+ def to_h
+ hash = {}
+ hash[:description] = description if description
+ hash[:required] = required unless required.nil?
+ hash[:content] = content if media_types.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Convert to Swagger 2.0 body parameter
+ def to_swagger2_parameter
+ primary_media_type = media_types.first
+ return nil unless primary_media_type
+
+ schema = primary_media_type.schema
+ schema_hash = schema.respond_to?(:to_h) ? schema.to_h : schema
+ {
+ name: 'body',
+ in: 'body',
+ required: required,
+ description: description,
+ schema: schema_hash
+ }.compact
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/response.rb b/lib/grape-swagger/openapi/response.rb
new file mode 100644
index 00000000..228738f9
--- /dev/null
+++ b/lib/grape-swagger/openapi/response.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Response definition.
+ class Response
+ attr_accessor :status_code, :description, :media_types, :headers,
+ :links, :extensions,
+ # Swagger 2.0 specific
+ :schema, :examples
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @media_types ||= []
+ @headers ||= {}
+ @extensions ||= {}
+ end
+
+ def add_media_type(mime_type, schema:, example: nil, examples: nil)
+ @media_types << MediaType.new(
+ mime_type: mime_type,
+ schema: schema,
+ example: example,
+ examples: examples
+ )
+ end
+
+ def add_header(name, schema:, description: nil)
+ @headers[name] = Header.new(
+ name: name,
+ schema: schema,
+ description: description
+ )
+ end
+
+ def content
+ media_types.each_with_object({}) do |mt, hash|
+ hash[mt.mime_type] = mt.to_h
+ end
+ end
+
+ def to_h
+ hash = { description: description || '' }
+ hash[:content] = content if media_types.any?
+ hash[:headers] = headers.transform_values(&:to_h) if headers.any?
+ hash[:links] = links if links&.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Swagger 2.0 style output
+ def to_swagger2_h
+ hash = { description: description || '' }
+ if schema
+ hash[:schema] = schema.respond_to?(:to_h) ? schema.to_h : schema
+ elsif media_types.any?
+ primary = media_types.first
+ hash[:schema] = primary.schema.respond_to?(:to_h) ? primary.schema.to_h : primary.schema if primary.schema
+ end
+ hash[:headers] = headers.transform_values(&:to_swagger2_h) if headers.any?
+ hash[:examples] = examples if examples&.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+
+ # Header definition.
+ class Header
+ attr_accessor :name, :description, :required, :deprecated,
+ :schema, :style, :explode,
+ :example, :examples, :extensions,
+ # Swagger 2.0 specific
+ :type, :format, :items
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @extensions ||= {}
+ end
+
+ def to_h
+ hash = {}
+ hash[:description] = description if description
+ hash[:required] = required if required
+ hash[:deprecated] = deprecated if deprecated
+ hash[:schema] = schema.respond_to?(:to_h) ? schema.to_h : schema if schema
+ hash[:style] = style if style
+ hash[:explode] = explode unless explode.nil?
+ hash[:example] = example unless example.nil?
+ hash[:examples] = examples if examples&.any?
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ def to_swagger2_h
+ hash = {}
+ hash[:description] = description if description
+ hash[:type] = type || schema&.type if type || schema&.type
+ hash[:format] = format || schema&.format if format || schema&.format
+ hash[:items] = items.respond_to?(:to_h) ? items.to_h : items if items
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/schema.rb b/lib/grape-swagger/openapi/schema.rb
new file mode 100644
index 00000000..2edf359e
--- /dev/null
+++ b/lib/grape-swagger/openapi/schema.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Version-agnostic JSON Schema representation.
+ # Used for request/response bodies, parameters, and component schemas.
+ class Schema
+ attr_accessor :type, :format, :properties, :items, :additional_properties,
+ :required, :enum, :nullable, :default,
+ :minimum, :maximum, :exclusive_minimum, :exclusive_maximum,
+ :min_length, :max_length, :min_items, :max_items,
+ :pattern, :multiple_of,
+ :all_of, :one_of, :any_of, :not,
+ :discriminator,
+ :canonical_name, :description, :example, :examples,
+ :read_only, :write_only, :deprecated,
+ :extensions,
+ # OpenAPI 3.1 specific
+ :json_schema, :content_media_type, :content_encoding
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @properties ||= {}
+ @required ||= []
+ @extensions ||= {}
+ end
+
+ def ref?
+ !canonical_name.nil? && !canonical_name.empty?
+ end
+
+ def primitive?
+ %w[string integer number boolean].include?(type) && !ref?
+ end
+
+ def array?
+ type == 'array'
+ end
+
+ def object?
+ type == 'object' || properties.any?
+ end
+
+ def composed?
+ all_of || one_of || any_of
+ end
+
+ def add_property(name, schema)
+ @properties[name.to_s] = schema
+ end
+
+ def mark_required(name)
+ @required << name.to_s unless @required.include?(name.to_s)
+ end
+
+ def to_h
+ hash = {}
+ add_basic_fields(hash)
+ add_numeric_constraints(hash)
+ add_string_constraints(hash)
+ add_array_fields(hash)
+ add_object_fields(hash)
+ add_composition_fields(hash)
+ add_extensions(hash)
+ hash
+ end
+
+ private
+
+ def add_basic_fields(hash)
+ hash[:type] = type if type
+ hash[:format] = format if format
+ hash[:description] = description if description
+ hash[:enum] = enum if enum&.any?
+ hash[:default] = default unless default.nil?
+ hash[:nullable] = nullable if nullable
+ hash[:example] = example unless example.nil?
+ hash[:examples] = examples if examples&.any?
+ hash[:readOnly] = read_only if read_only
+ hash[:writeOnly] = write_only if write_only
+ hash[:deprecated] = deprecated if deprecated
+ end
+
+ def add_numeric_constraints(hash)
+ hash[:minimum] = minimum if minimum
+ hash[:maximum] = maximum if maximum
+ hash[:exclusiveMinimum] = exclusive_minimum if exclusive_minimum
+ hash[:exclusiveMaximum] = exclusive_maximum if exclusive_maximum
+ hash[:multipleOf] = multiple_of if multiple_of
+ end
+
+ def add_string_constraints(hash)
+ hash[:minLength] = min_length if min_length
+ hash[:maxLength] = max_length if max_length
+ hash[:pattern] = pattern if pattern
+ end
+
+ def add_array_fields(hash)
+ hash[:minItems] = min_items if min_items
+ hash[:maxItems] = max_items if max_items
+ hash[:items] = items.is_a?(Schema) ? items.to_h : items if items
+ end
+
+ def add_object_fields(hash)
+ if properties.any?
+ hash[:properties] = properties.transform_values { |p| p.is_a?(Schema) ? p.to_h : p }
+ end
+ hash[:required] = required if required.any?
+ hash[:additionalProperties] = additional_properties unless additional_properties.nil?
+ end
+
+ def add_composition_fields(hash)
+ hash[:allOf] = all_of.map { |s| s.is_a?(Schema) ? s.to_h : s } if all_of&.any?
+ hash[:oneOf] = one_of.map { |s| s.is_a?(Schema) ? s.to_h : s } if one_of&.any?
+ hash[:anyOf] = any_of.map { |s| s.is_a?(Schema) ? s.to_h : s } if any_of&.any?
+ hash[:not] = self.not.is_a?(Schema) ? self.not.to_h : self.not if self.not
+ hash[:discriminator] = discriminator if discriminator
+ end
+
+ def add_extensions(hash)
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/security_scheme.rb b/lib/grape-swagger/openapi/security_scheme.rb
new file mode 100644
index 00000000..6c48e93a
--- /dev/null
+++ b/lib/grape-swagger/openapi/security_scheme.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Security scheme definition.
+ class SecurityScheme
+ TYPES = %w[apiKey http oauth2 openIdConnect].freeze
+ # Swagger 2.0 types
+ SWAGGER2_TYPES = %w[basic apiKey oauth2].freeze
+
+ attr_accessor :type, :name, :description,
+ :location, # 'in' field - query, header, cookie
+ :scheme, :bearer_format, # for http type
+ :flows, # for oauth2
+ :open_id_connect_url, # for openIdConnect
+ :extensions
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @extensions ||= {}
+ end
+
+ def to_h
+ hash = { type: type }
+ hash[:description] = description if description
+
+ case type
+ when 'apiKey'
+ hash[:name] = name
+ hash[:in] = location
+ when 'http'
+ hash[:scheme] = scheme
+ hash[:bearerFormat] = bearer_format if bearer_format
+ when 'oauth2'
+ hash[:flows] = flows if flows
+ when 'openIdConnect'
+ hash[:openIdConnectUrl] = open_id_connect_url
+ end
+
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ # Swagger 2.0 style output
+ def to_swagger2_h
+ hash = { type: swagger2_type }
+ hash[:description] = description if description
+
+ case swagger2_type
+ when 'apiKey'
+ hash[:name] = name
+ hash[:in] = location
+ when 'basic'
+ # No additional fields
+ when 'oauth2'
+ # Convert OAS3 flows to Swagger 2.0 flow
+ if flows
+ flow_type = flows.keys.first
+ flow = flows[flow_type]
+ hash[:flow] = swagger2_flow_type(flow_type)
+ hash[:authorizationUrl] = flow[:authorizationUrl] if flow[:authorizationUrl]
+ hash[:tokenUrl] = flow[:tokenUrl] if flow[:tokenUrl]
+ hash[:scopes] = flow[:scopes] if flow[:scopes]
+ end
+ end
+
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+
+ private
+
+ def swagger2_type
+ case type
+ when 'http'
+ scheme == 'basic' ? 'basic' : 'apiKey' # bearer becomes apiKey in 2.0
+ else
+ type
+ end
+ end
+
+ def swagger2_flow_type(oas3_flow)
+ case oas3_flow.to_s
+ when 'implicit' then 'implicit'
+ when 'password' then 'password'
+ when 'clientCredentials' then 'application'
+ when 'authorizationCode' then 'accessCode'
+ else oas3_flow.to_s
+ end
+ end
+ end
+
+ # OAuth2 flow definition.
+ class OAuthFlow
+ attr_accessor :authorization_url, :token_url, :refresh_url, :scopes
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @scopes ||= {}
+ end
+
+ def to_h
+ hash = {}
+ hash[:authorizationUrl] = authorization_url if authorization_url
+ hash[:tokenUrl] = token_url if token_url
+ hash[:refreshUrl] = refresh_url if refresh_url
+ hash[:scopes] = scopes if scopes.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/server.rb b/lib/grape-swagger/openapi/server.rb
new file mode 100644
index 00000000..e2756798
--- /dev/null
+++ b/lib/grape-swagger/openapi/server.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Server definition for OpenAPI 3.x.
+ # For Swagger 2.0, this is converted to host/basePath/schemes.
+ class Server
+ attr_accessor :url, :description, :variables
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @variables ||= {}
+ end
+
+ # Build from Swagger 2.0 components
+ def self.from_swagger2(host:, base_path: nil, scheme: 'https')
+ url = "#{scheme}://#{host}"
+ url += base_path if base_path && base_path != '/'
+ new(url: url)
+ end
+
+ def to_h
+ hash = { url: url }
+ hash[:description] = description if description
+ hash[:variables] = variables.transform_values(&:to_h) if variables.any?
+ hash
+ end
+ end
+
+ # Server variable for templated URLs.
+ class ServerVariable
+ attr_accessor :default, :description, :enum
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ end
+
+ def to_h
+ hash = { default: default }
+ hash[:description] = description if description
+ hash[:enum] = enum if enum&.any?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/grape-swagger/openapi/tag.rb b/lib/grape-swagger/openapi/tag.rb
new file mode 100644
index 00000000..5664a5d7
--- /dev/null
+++ b/lib/grape-swagger/openapi/tag.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module GrapeSwagger
+ module OpenAPI
+ # Tag definition for grouping operations.
+ class Tag
+ attr_accessor :name, :description, :external_docs, :extensions
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ @extensions ||= {}
+ end
+
+ def to_h
+ hash = { name: name }
+ hash[:description] = description if description
+ hash[:externalDocs] = external_docs.to_h if external_docs
+ extensions.each { |k, v| hash[k] = v } if extensions.any?
+ hash
+ end
+ end
+
+ # External documentation reference.
+ class ExternalDoc
+ attr_accessor :url, :description
+
+ def initialize(attrs = {})
+ attrs.each { |k, v| public_send("#{k}=", v) if respond_to?("#{k}=") }
+ end
+
+ def to_h
+ hash = { url: url }
+ hash[:description] = description if description
+ hash
+ end
+ end
+ end
+end
diff --git a/spec/issues/539_array_post_body_spec.rb b/spec/issues/539_array_post_body_spec.rb
index 2476db74..ce9a78b2 100644
--- a/spec/issues/539_array_post_body_spec.rb
+++ b/spec/issues/539_array_post_body_spec.rb
@@ -7,9 +7,9 @@
Class.new(Grape::API) do
namespace :issue_539 do
class Element < Grape::Entity
- expose :id
- expose :description
- expose :role
+ expose :id, documentation: { type: String, required: true }
+ expose :description, documentation: { type: String, required: true }
+ expose :role, documentation: { type: String, required: true }
end
class ArrayOfElements < Grape::Entity
diff --git a/spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb b/spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb
index 46f6237e..b467a650 100644
--- a/spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb
+++ b/spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb
@@ -93,10 +93,10 @@ class EntityWithNestedEmptyEntity < Grape::Entity
end
specify do
+ # hidden_prop is hidden, so it shouldn't appear in properties or required
expect(hidden_entity_definition).to eql({
'type' => 'object',
- 'properties' => {},
- 'required' => ['hidden_prop']
+ 'properties' => {}
})
end
diff --git a/spec/openapi_v3/additional_properties_spec.rb b/spec/openapi_v3/additional_properties_spec.rb
new file mode 100644
index 00000000..ec805e57
--- /dev/null
+++ b/spec/openapi_v3/additional_properties_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'additional_properties in OpenAPI 3.0' do
+ let(:app) do
+ Class.new(Grape::API) do
+ namespace :things do
+ class Element < Grape::Entity
+ expose :id
+ end
+
+ params do
+ optional :closed, type: Hash, documentation: { additional_properties: false, in: 'body' } do
+ requires :only
+ end
+ optional :open, type: Hash, documentation: { additional_properties: true }
+ optional :type_limited, type: Hash, documentation: { additional_properties: String }
+ optional :ref_limited, type: Hash, documentation: { additional_properties: Element }
+ optional :fallback, type: Hash, documentation: { additional_properties: { type: 'integer' } }
+ end
+ post do
+ present params
+ end
+ end
+
+ add_swagger_documentation format: :json, openapi_version: '3.0', models: [Element]
+ end
+ end
+
+ subject do
+ get '/swagger_doc/things'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'OpenAPI version' do
+ it 'returns OAS 3.0' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+ end
+
+ describe 'POST request body' do
+ let(:operation) { subject['paths']['/things']['post'] }
+
+ it 'has requestBody instead of body parameter' do
+ params = operation['parameters'] || []
+ body_params = params.select { |p| p['in'] == 'body' }
+ expect(body_params).to be_empty
+ expect(operation['requestBody']).to be_present
+ end
+
+ it 'references schema in components' do
+ expect(operation['requestBody']['content']['application/json']['schema']).to eq(
+ { '$ref' => '#/components/schemas/postThings' }
+ )
+ end
+ end
+
+ describe 'schema with additional_properties' do
+ let(:schema) { subject['components']['schemas']['postThings'] }
+
+ it 'has object type' do
+ expect(schema['type']).to eq('object')
+ end
+
+ describe 'closed property (additional_properties: false)' do
+ let(:closed) { schema['properties']['closed'] }
+
+ it 'sets additionalProperties to false' do
+ expect(closed['additionalProperties']).to eq(false)
+ end
+
+ it 'has nested properties' do
+ expect(closed['properties']['only']).to eq({ 'type' => 'string' })
+ end
+
+ it 'marks required fields' do
+ expect(closed['required']).to eq(['only'])
+ end
+ end
+
+ describe 'open property (additional_properties: true)' do
+ let(:open_prop) { schema['properties']['open'] }
+
+ it 'sets additionalProperties to true' do
+ expect(open_prop['additionalProperties']).to eq(true)
+ end
+ end
+
+ describe 'type_limited property (additional_properties: String)' do
+ let(:type_limited) { schema['properties']['type_limited'] }
+
+ it 'sets additionalProperties to type schema' do
+ expect(type_limited['additionalProperties']).to eq({ 'type' => 'string' })
+ end
+ end
+
+ describe 'ref_limited property (additional_properties: Entity)' do
+ let(:ref_limited) { schema['properties']['ref_limited'] }
+
+ it 'sets additionalProperties to $ref using components/schemas path' do
+ expect(ref_limited['additionalProperties']).to eq(
+ { '$ref' => '#/components/schemas/Element' }
+ )
+ end
+ end
+
+ describe 'fallback property (additional_properties: hash)' do
+ let(:fallback) { schema['properties']['fallback'] }
+
+ it 'sets additionalProperties from hash' do
+ expect(fallback['additionalProperties']).to eq({ 'type' => 'integer' })
+ end
+ end
+ end
+
+ describe 'Element schema in components' do
+ it 'defines Element schema' do
+ expect(subject['components']['schemas']).to have_key('Element')
+ end
+ end
+end
diff --git a/spec/openapi_v3/composition_schemas_spec.rb b/spec/openapi_v3/composition_schemas_spec.rb
new file mode 100644
index 00000000..57fe7829
--- /dev/null
+++ b/spec/openapi_v3/composition_schemas_spec.rb
@@ -0,0 +1,187 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'SchemaBuilder composition support' do
+ let(:definitions) { {} }
+ let(:builder) { GrapeSwagger::OpenAPI::Builder::SchemaBuilder.new(definitions) }
+
+ describe '#build_from_definition with allOf' do
+ let(:definition) do
+ {
+ allOf: [
+ { '$ref' => '#/definitions/BaseModel' },
+ { type: 'object', properties: { name: { type: 'string' } } }
+ ]
+ }
+ end
+
+ it 'creates schema with all_of array' do
+ schema = builder.build_from_definition(definition)
+
+ expect(schema.all_of).to be_an(Array)
+ expect(schema.all_of.length).to eq(2)
+
+ # First element is a reference
+ expect(schema.all_of[0].canonical_name).to eq('BaseModel')
+
+ # Second element is an inline schema
+ expect(schema.all_of[1].type).to eq('object')
+ expect(schema.all_of[1].properties).to have_key('name')
+ end
+ end
+
+ describe '#build_from_definition with oneOf' do
+ let(:definition) do
+ {
+ oneOf: [
+ { '$ref' => '#/definitions/Cat' },
+ { '$ref' => '#/definitions/Dog' }
+ ]
+ }
+ end
+
+ it 'creates schema with one_of array' do
+ schema = builder.build_from_definition(definition)
+
+ expect(schema.one_of).to be_an(Array)
+ expect(schema.one_of.length).to eq(2)
+ expect(schema.one_of[0].canonical_name).to eq('Cat')
+ expect(schema.one_of[1].canonical_name).to eq('Dog')
+ end
+ end
+
+ describe '#build_from_definition with anyOf' do
+ let(:definition) do
+ {
+ anyOf: [
+ { type: 'string' },
+ { type: 'integer' }
+ ]
+ }
+ end
+
+ it 'creates schema with any_of array' do
+ schema = builder.build_from_definition(definition)
+
+ expect(schema.any_of).to be_an(Array)
+ expect(schema.any_of.length).to eq(2)
+ expect(schema.any_of[0].type).to eq('string')
+ expect(schema.any_of[1].type).to eq('integer')
+ end
+ end
+
+ describe '#build_from_definition with nested composition' do
+ let(:definition) do
+ {
+ oneOf: [
+ {
+ allOf: [
+ { '$ref' => '#/definitions/Base' },
+ { type: 'object', properties: { catName: { type: 'string' } } }
+ ]
+ },
+ {
+ allOf: [
+ { '$ref' => '#/definitions/Base' },
+ { type: 'object', properties: { dogName: { type: 'string' } } }
+ ]
+ }
+ ]
+ }
+ end
+
+ it 'creates nested composition schemas' do
+ schema = builder.build_from_definition(definition)
+
+ expect(schema.one_of).to be_an(Array)
+ expect(schema.one_of.length).to eq(2)
+
+ # First oneOf option has allOf
+ expect(schema.one_of[0].all_of).to be_an(Array)
+ expect(schema.one_of[0].all_of.length).to eq(2)
+ expect(schema.one_of[0].all_of[0].canonical_name).to eq('Base')
+ expect(schema.one_of[0].all_of[1].properties).to have_key('catName')
+
+ # Second oneOf option has allOf
+ expect(schema.one_of[1].all_of).to be_an(Array)
+ expect(schema.one_of[1].all_of[0].canonical_name).to eq('Base')
+ expect(schema.one_of[1].all_of[1].properties).to have_key('dogName')
+ end
+ end
+end
+
+describe 'OAS30 exporter composition support' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ let(:schema_with_one_of) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.one_of = [
+ GrapeSwagger::OpenAPI::Schema.new(canonical_name: 'Cat'),
+ GrapeSwagger::OpenAPI::Schema.new(canonical_name: 'Dog')
+ ]
+ end
+ end
+
+ let(:schema_with_any_of) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.any_of = [
+ GrapeSwagger::OpenAPI::Schema.new(type: 'string'),
+ GrapeSwagger::OpenAPI::Schema.new(type: 'integer')
+ ]
+ end
+ end
+
+ let(:schema_with_all_of) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.all_of = [
+ GrapeSwagger::OpenAPI::Schema.new(canonical_name: 'Base'),
+ GrapeSwagger::OpenAPI::Schema.new(type: 'object').tap do |obj|
+ obj.add_property('extra', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ end
+ ]
+ end
+ end
+
+ before do
+ spec.components.add_schema('Pet', schema_with_one_of)
+ spec.components.add_schema('Value', schema_with_any_of)
+ spec.components.add_schema('ExtendedBase', schema_with_all_of)
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports oneOf with schema references' do
+ pet_schema = subject[:components][:schemas]['Pet']
+
+ expect(pet_schema[:oneOf]).to be_an(Array)
+ expect(pet_schema[:oneOf].length).to eq(2)
+ expect(pet_schema[:oneOf][0]).to eq({ '$ref' => '#/components/schemas/Cat' })
+ expect(pet_schema[:oneOf][1]).to eq({ '$ref' => '#/components/schemas/Dog' })
+ end
+
+ it 'exports anyOf with inline schemas' do
+ value_schema = subject[:components][:schemas]['Value']
+
+ expect(value_schema[:anyOf]).to be_an(Array)
+ expect(value_schema[:anyOf].length).to eq(2)
+ expect(value_schema[:anyOf][0]).to eq({ type: 'string' })
+ expect(value_schema[:anyOf][1]).to eq({ type: 'integer' })
+ end
+
+ it 'exports allOf with mixed refs and inline schemas' do
+ extended_schema = subject[:components][:schemas]['ExtendedBase']
+
+ expect(extended_schema[:allOf]).to be_an(Array)
+ expect(extended_schema[:allOf].length).to eq(2)
+ expect(extended_schema[:allOf][0]).to eq({ '$ref' => '#/components/schemas/Base' })
+ expect(extended_schema[:allOf][1]).to eq({
+ type: 'object',
+ properties: { 'extra' => { type: 'string' } }
+ })
+ end
+end
diff --git a/spec/openapi_v3/detail_spec.rb b/spec/openapi_v3/detail_spec.rb
new file mode 100644
index 00000000..618abd7a
--- /dev/null
+++ b/spec/openapi_v3/detail_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'summary and description (detail) in OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class DetailApi < Grape::API
+ format :json
+
+ desc 'This returns something',
+ detail: 'detailed description of the route',
+ entity: Entities::UseResponse,
+ failure: [{ code: 400, model: Entities::ApiError }]
+ get '/use_detail' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'This returns something' do
+ detail 'detailed description of the route inside the `desc` block'
+ entity Entities::UseResponse
+ failure [{ code: 400, model: Entities::ApiError }]
+ end
+ get '/use_detail_block' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'Short summary only'
+ get '/summary_only' do
+ {}
+ end
+
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::DetailApi
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'OAS3 format' do
+ it 'returns openapi 3.0.3' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+ end
+
+ describe 'detail as inline option' do
+ let(:operation) { subject['paths']['/use_detail']['get'] }
+
+ it 'has summary from desc' do
+ expect(operation).to include('summary')
+ expect(operation['summary']).to eq 'This returns something'
+ end
+
+ it 'has description from detail' do
+ expect(operation).to include('description')
+ expect(operation['description']).to eq 'detailed description of the route'
+ end
+
+ it 'has response with content wrapper' do
+ expect(operation['responses']['200']['content']).to be_present
+ end
+
+ it 'has failure response with schema ref' do
+ expect(operation['responses']['400']['content']['application/json']['schema']['$ref']).to eq(
+ '#/components/schemas/ApiError'
+ )
+ end
+ end
+
+ describe 'detail inside desc block' do
+ let(:operation) { subject['paths']['/use_detail_block']['get'] }
+
+ it 'has summary from desc' do
+ expect(operation).to include('summary')
+ expect(operation['summary']).to eq 'This returns something'
+ end
+
+ it 'has description from detail block' do
+ expect(operation).to include('description')
+ expect(operation['description']).to eq 'detailed description of the route inside the `desc` block'
+ end
+ end
+
+ describe 'summary only (no detail)' do
+ let(:operation) { subject['paths']['/summary_only']['get'] }
+
+ it 'has description matching summary when no detail provided' do
+ # In grape-swagger, when no detail is provided, description = summary
+ expect(operation['description']).to eq 'Short summary only'
+ end
+ end
+end
diff --git a/spec/openapi_v3/discriminator_spec.rb b/spec/openapi_v3/discriminator_spec.rb
new file mode 100644
index 00000000..59fee55a
--- /dev/null
+++ b/spec/openapi_v3/discriminator_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Discriminator in OpenAPI 3.0' do
+ describe 'SchemaBuilder preserves discriminator' do
+ let(:definitions) { {} }
+ let(:builder) { GrapeSwagger::OpenAPI::Builder::SchemaBuilder.new(definitions) }
+
+ it 'preserves discriminator field from definition' do
+ definition = {
+ type: 'object',
+ properties: {
+ type: { type: 'string' },
+ name: { type: 'string' }
+ },
+ discriminator: 'type'
+ }
+
+ schema = builder.build_from_definition(definition)
+
+ expect(schema.discriminator).to eq('type')
+ end
+ end
+
+ describe 'OAS30 exporter handles discriminator' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ let(:pet_schema) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.type = 'object'
+ s.discriminator = 'type'
+ s.add_property('type', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ s.add_property('name', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ s.mark_required('type')
+ s.mark_required('name')
+ end
+ end
+
+ let(:cat_schema) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.all_of = [
+ GrapeSwagger::OpenAPI::Schema.new(canonical_name: 'Pet'),
+ GrapeSwagger::OpenAPI::Schema.new(type: 'object').tap do |props|
+ props.add_property('huntingSkill', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ end
+ ]
+ end
+ end
+
+ before do
+ spec.components.add_schema('Pet', pet_schema)
+ spec.components.add_schema('Cat', cat_schema)
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports discriminator property on base schema' do
+ expect(subject[:components][:schemas]['Pet'][:discriminator]).to eq('type')
+ end
+
+ it 'exports allOf for child schema' do
+ cat = subject[:components][:schemas]['Cat']
+ expect(cat[:allOf]).to be_an(Array)
+ expect(cat[:allOf].length).to eq(2)
+ end
+
+ it 'references parent in allOf using OAS3 path' do
+ cat = subject[:components][:schemas]['Cat']
+ refs = cat[:allOf].select { |item| item['$ref'] }
+ expect(refs.first['$ref']).to eq('#/components/schemas/Pet')
+ end
+
+ it 'includes child properties in allOf' do
+ cat = subject[:components][:schemas]['Cat']
+ props = cat[:allOf].find { |item| item[:properties] }
+ expect(props[:properties]).to have_key('huntingSkill')
+ end
+ end
+
+ describe 'OAS3 discriminator object format' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ let(:pet_schema_with_mapping) do
+ GrapeSwagger::OpenAPI::Schema.new.tap do |s|
+ s.type = 'object'
+ # OAS3 discriminator object format
+ s.discriminator = {
+ propertyName: 'petType',
+ mapping: {
+ 'cat' => '#/components/schemas/Cat',
+ 'dog' => '#/components/schemas/Dog'
+ }
+ }
+ s.add_property('petType', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ end
+ end
+
+ before do
+ spec.components.add_schema('Pet', pet_schema_with_mapping)
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'preserves discriminator object with propertyName and mapping' do
+ discriminator = subject[:components][:schemas]['Pet'][:discriminator]
+
+ expect(discriminator).to be_a(Hash)
+ expect(discriminator[:propertyName]).to eq('petType')
+ expect(discriminator[:mapping]).to eq({
+ 'cat' => '#/components/schemas/Cat',
+ 'dog' => '#/components/schemas/Dog'
+ })
+ end
+ end
+end
diff --git a/spec/openapi_v3/extensions_spec.rb b/spec/openapi_v3/extensions_spec.rb
new file mode 100644
index 00000000..67b8e497
--- /dev/null
+++ b/spec/openapi_v3/extensions_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'x- extensions in OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class ExtensionsApi < Grape::API
+ format :json
+
+ route_setting :x_path, some: 'stuff'
+
+ desc 'This returns something with extension on path level',
+ params: Entities::UseResponse.documentation,
+ failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }]
+ get '/path_extension' do
+ { 'declared_params' => declared(params) }
+ end
+
+ route_setting :x_operation, some: 'stuff'
+
+ desc 'This returns something with extension on verb level',
+ params: Entities::UseResponse.documentation,
+ failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }]
+ params do
+ requires :id, type: Integer
+ end
+ get '/verb_extension' do
+ { 'declared_params' => declared(params) }
+ end
+
+ route_setting :x_def, for: 200, some: 'stuff'
+
+ desc 'This returns something with extension on definition level',
+ params: Entities::ResponseItem.documentation,
+ success: Entities::ResponseItem,
+ failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }]
+ get '/definitions_extension' do
+ { 'declared_params' => declared(params) }
+ end
+
+ route_setting :x_def, [{ for: 422, other: 'stuff' }, { for: 200, some: 'stuff' }]
+
+ desc 'This returns something with extension on definition level',
+ success: Entities::OtherItem
+ get '/non_existent_status_definitions_extension' do
+ { 'declared_params' => declared(params) }
+ end
+
+ route_setting :x_def, [{ for: 422, other: 'stuff' }, { for: 200, some: 'stuff' }]
+
+ desc 'This returns something with extension on definition level',
+ success: Entities::OtherItem,
+ failure: [{ code: 422, message: 'NotFound', model: Entities::SecondApiError }]
+ get '/multiple_definitions_extension' do
+ { 'declared_params' => declared(params) }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0', x: { some: 'stuff' })
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::ExtensionsApi
+ end
+
+ describe 'OAS3 format' do
+ subject do
+ get '/swagger_doc/path_extension'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns openapi 3.0.3' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'uses components/schemas instead of definitions' do
+ expect(subject['definitions']).to be_nil
+ expect(subject['components']['schemas']).to be_present
+ end
+ end
+
+ describe 'extension on root level' do
+ subject do
+ get '/swagger_doc/path_extension'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has x- extension at root' do
+ expect(subject).to include 'x-some'
+ expect(subject['x-some']).to eq 'stuff'
+ end
+ end
+
+ describe 'extension on verb level' do
+ subject do
+ get '/swagger_doc/verb_extension'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has x- extension on operation' do
+ expect(subject['paths']['/verb_extension']['get']).to include 'x-some'
+ expect(subject['paths']['/verb_extension']['get']['x-some']).to eq 'stuff'
+ end
+ end
+
+ describe 'schemas in components' do
+ subject do
+ get '/swagger_doc/definitions_extension'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has schemas defined in components' do
+ expect(subject['components']['schemas']).to have_key('ResponseItem')
+ expect(subject['components']['schemas']).to have_key('ApiError')
+ end
+
+ it 'schemas have proper structure' do
+ expect(subject['components']['schemas']['ResponseItem']['type']).to eq('object')
+ end
+ end
+
+ describe 'multiple schemas' do
+ subject do
+ get '/swagger_doc/multiple_definitions_extension'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has multiple schemas in components' do
+ expect(subject['components']['schemas']).to have_key('OtherItem')
+ expect(subject['components']['schemas']).to have_key('SecondApiError')
+ end
+ end
+end
diff --git a/spec/openapi_v3/file_upload_spec.rb b/spec/openapi_v3/file_upload_spec.rb
new file mode 100644
index 00000000..d920073c
--- /dev/null
+++ b/spec/openapi_v3/file_upload_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'File upload in OpenAPI 3.0' do
+ before :all do
+ module TheApi
+ class FileUploadOAS3Api < Grape::API
+ format :json
+
+ desc 'Upload a file'
+ params do
+ requires :file, type: File, desc: 'The file to upload'
+ optional :description, type: String, desc: 'File description'
+ end
+ post '/upload' do
+ { filename: params[:file][:filename] }
+ end
+
+ desc 'Upload multiple files'
+ params do
+ requires :files, type: Array[File], desc: 'Multiple files to upload'
+ end
+ post '/upload_multiple' do
+ { count: params[:files].length }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ TheApi::FileUploadOAS3Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'single file upload' do
+ let(:upload_op) { subject['paths']['/upload']['post'] }
+ let(:schema) { subject['components']['schemas']['postUpload'] }
+
+ it 'uses requestBody for file upload' do
+ expect(upload_op).to have_key('requestBody')
+ end
+
+ it 'references schema in requestBody content' do
+ content = upload_op['requestBody']['content']
+ expect(content['application/json']['schema']['$ref']).to eq('#/components/schemas/postUpload')
+ end
+
+ it 'converts file type to string with binary format in schema' do
+ file_prop = schema['properties']['file']
+
+ expect(file_prop['type']).to eq('string')
+ expect(file_prop['format']).to eq('binary')
+ expect(file_prop['description']).to eq('The file to upload')
+ end
+
+ it 'marks file as required' do
+ expect(schema['required']).to include('file')
+ end
+ end
+
+ describe 'multiple file upload' do
+ let(:schema) { subject['components']['schemas']['postUploadMultiple'] }
+
+ it 'handles array of files with binary format' do
+ files_prop = schema['properties']['files']
+
+ expect(files_prop['type']).to eq('array')
+ expect(files_prop['items']['type']).to eq('string')
+ expect(files_prop['items']['format']).to eq('binary')
+ end
+ end
+end
diff --git a/spec/openapi_v3/form_data_spec.rb b/spec/openapi_v3/form_data_spec.rb
new file mode 100644
index 00000000..2af6740b
--- /dev/null
+++ b/spec/openapi_v3/form_data_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Form data in OpenAPI 3.0' do
+ before :all do
+ module TheApi
+ class FormDataOAS3Api < Grape::API
+ format :json
+
+ desc 'Login with form data',
+ consumes: ['application/x-www-form-urlencoded']
+ params do
+ requires :username, type: String, desc: 'Username'
+ requires :password, type: String, desc: 'Password'
+ optional :remember_me, type: Boolean, desc: 'Remember me'
+ end
+ post '/login' do
+ { token: 'abc123' }
+ end
+
+ desc 'Upload with multipart form',
+ consumes: ['multipart/form-data']
+ params do
+ requires :file, type: File, desc: 'File to upload'
+ requires :name, type: String, desc: 'File name'
+ optional :description, type: String, desc: 'File description'
+ end
+ post '/upload' do
+ { uploaded: true }
+ end
+
+ desc 'Mixed params with form data'
+ params do
+ requires :id, type: Integer, desc: 'Resource ID'
+ end
+ post '/items/:id' do
+ params do
+ requires :name, type: String, desc: 'Item name'
+ end
+ { id: params[:id] }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ TheApi::FormDataOAS3Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'URL-encoded form data' do
+ let(:login_op) { subject['paths']['/login']['post'] }
+
+ it 'converts formData to requestBody' do
+ expect(login_op).to have_key('requestBody')
+ expect(login_op).not_to have_key('parameters')
+ end
+
+ it 'uses application/x-www-form-urlencoded content type' do
+ content = login_op['requestBody']['content']
+ expect(content).to have_key('application/x-www-form-urlencoded')
+ end
+
+ it 'includes all form fields as schema properties' do
+ schema = login_op['requestBody']['content']['application/x-www-form-urlencoded']['schema']
+ expect(schema['properties']).to have_key('username')
+ expect(schema['properties']).to have_key('password')
+ expect(schema['properties']).to have_key('remember_me')
+ end
+
+ it 'marks required fields in schema' do
+ schema = login_op['requestBody']['content']['application/x-www-form-urlencoded']['schema']
+ expect(schema['required']).to include('username')
+ expect(schema['required']).to include('password')
+ expect(schema['required']).not_to include('remember_me')
+ end
+
+ it 'preserves field types' do
+ schema = login_op['requestBody']['content']['application/x-www-form-urlencoded']['schema']
+ expect(schema['properties']['username']['type']).to eq('string')
+ expect(schema['properties']['remember_me']['type']).to eq('boolean')
+ end
+
+ it 'sets requestBody as required when has required fields' do
+ expect(login_op['requestBody']['required']).to be true
+ end
+ end
+
+ describe 'multipart form data with file' do
+ let(:upload_op) { subject['paths']['/upload']['post'] }
+
+ it 'converts formData to requestBody' do
+ expect(upload_op).to have_key('requestBody')
+ end
+
+ it 'uses multipart/form-data content type' do
+ content = upload_op['requestBody']['content']
+ expect(content).to have_key('multipart/form-data')
+ end
+
+ it 'converts file type to string with binary format' do
+ schema = upload_op['requestBody']['content']['multipart/form-data']['schema']
+ file_prop = schema['properties']['file']
+ expect(file_prop['type']).to eq('string')
+ expect(file_prop['format']).to eq('binary')
+ end
+
+ it 'includes non-file fields alongside file' do
+ schema = upload_op['requestBody']['content']['multipart/form-data']['schema']
+ expect(schema['properties']).to have_key('name')
+ expect(schema['properties']['name']['type']).to eq('string')
+ end
+ end
+
+ describe 'no formData parameters in output' do
+ it 'does not have any parameters with in: formData' do
+ json_string = subject.to_json
+ expect(json_string).not_to include('"in":"formData"')
+ expect(json_string).not_to include('"in": "formData"')
+ end
+ end
+end
diff --git a/spec/openapi_v3/integration_spec.rb b/spec/openapi_v3/integration_spec.rb
new file mode 100644
index 00000000..2373a031
--- /dev/null
+++ b/spec/openapi_v3/integration_spec.rb
@@ -0,0 +1,502 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OpenAPI 3.x Integration Tests' do
+ describe 'Complete API with multiple features' do
+ before :all do
+ module IntegrationTest
+ module Entities
+ class Error < Grape::Entity
+ expose :code, documentation: { type: Integer, desc: 'Error code', required: true }
+ expose :message, documentation: { type: String, desc: 'Error message', required: true }
+ end
+
+ class ValidationError < Grape::Entity
+ expose :code, documentation: { type: Integer, desc: 'Error code', required: true }
+ expose :message, documentation: { type: String, desc: 'Error message', required: true }
+ expose :fields, documentation: { type: String, is_array: true, desc: 'Invalid fields' }
+ end
+
+ class Address < Grape::Entity
+ expose :street, documentation: { type: String, desc: 'Street address', required: true }
+ expose :city, documentation: { type: String, desc: 'City', required: true }
+ expose :country, documentation: { type: String, desc: 'Country code', required: true }
+ expose :postal_code, documentation: { type: String, desc: 'Postal code' }
+ end
+
+ class User < Grape::Entity
+ expose :id, documentation: { type: Integer, desc: 'User ID', required: true }
+ expose :email, documentation: { type: String, desc: 'Email address', required: true }
+ expose :name, documentation: { type: String, desc: 'Full name', required: true }
+ expose :role, documentation: { type: String, desc: 'User role', values: %w[admin user guest] }
+ expose :address, using: Address, documentation: { desc: 'User address' }
+ expose :created_at, documentation: { type: DateTime, desc: 'Creation timestamp' }
+ end
+
+ class UserList < Grape::Entity
+ expose :users, using: User, documentation: { is_array: true, desc: 'List of users', required: true }
+ expose :total, documentation: { type: Integer, desc: 'Total count', required: true }
+ expose :page, documentation: { type: Integer, desc: 'Current page', required: true }
+ end
+
+ class CreateUserRequest < Grape::Entity
+ expose :email, documentation: { type: String, desc: 'Email address', required: true }
+ expose :name, documentation: { type: String, desc: 'Full name', required: true }
+ expose :password, documentation: { type: String, desc: 'Password', required: true }
+ expose :role, documentation: { type: String, desc: 'User role', values: %w[admin user guest] }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+ prefix :api
+ version 'v1', using: :path
+
+ helpers do
+ def current_user
+ OpenStruct.new(id: 1, role: 'admin')
+ end
+ end
+
+ resource :users do
+ desc 'List all users',
+ success: Entities::UserList,
+ failure: [
+ { code: 401, message: 'Unauthorized', model: Entities::Error },
+ { code: 403, message: 'Forbidden', model: Entities::Error }
+ ],
+ tags: ['users']
+ params do
+ optional :page, type: Integer, default: 1, desc: 'Page number'
+ optional :per_page, type: Integer, default: 20, desc: 'Items per page'
+ optional :search, type: String, desc: 'Search query'
+ optional :role, type: String, values: %w[admin user guest], desc: 'Filter by role'
+ end
+ get do
+ present({ users: [], total: 0, page: params[:page] }, with: Entities::UserList)
+ end
+
+ desc 'Create a new user',
+ success: { code: 201, model: Entities::User },
+ failure: [
+ { code: 400, message: 'Bad Request', model: Entities::ValidationError },
+ { code: 401, message: 'Unauthorized', model: Entities::Error },
+ { code: 409, message: 'Conflict', model: Entities::Error }
+ ],
+ consumes: ['application/json'],
+ tags: ['users']
+ params do
+ requires :email, type: String, regexp: /\A[^@]+@[^@]+\z/, desc: 'Email address'
+ requires :name, type: String, desc: 'Full name'
+ requires :password, type: String, desc: 'Password (min 8 chars)'
+ optional :role, type: String, values: %w[admin user guest], default: 'user', desc: 'User role'
+ end
+ post do
+ present OpenStruct.new(id: 1, email: params[:email], name: params[:name]),
+ with: Entities::User
+ end
+
+ route_param :id, type: Integer do
+ desc 'Get user by ID',
+ success: Entities::User,
+ failure: [
+ { code: 401, message: 'Unauthorized', model: Entities::Error },
+ { code: 404, message: 'Not Found', model: Entities::Error }
+ ],
+ tags: ['users']
+ get do
+ present OpenStruct.new(id: params[:id], email: 'test@example.com'),
+ with: Entities::User
+ end
+
+ desc 'Update user',
+ success: Entities::User,
+ failure: [
+ { code: 400, message: 'Bad Request', model: Entities::ValidationError },
+ { code: 401, message: 'Unauthorized', model: Entities::Error },
+ { code: 404, message: 'Not Found', model: Entities::Error }
+ ],
+ consumes: ['application/json'],
+ tags: ['users']
+ params do
+ optional :email, type: String, desc: 'Email address'
+ optional :name, type: String, desc: 'Full name'
+ optional :role, type: String, values: %w[admin user guest], desc: 'User role'
+ end
+ put do
+ present OpenStruct.new(id: params[:id]), with: Entities::User
+ end
+
+ desc 'Delete user',
+ failure: [
+ { code: 401, message: 'Unauthorized', model: Entities::Error },
+ { code: 404, message: 'Not Found', model: Entities::Error }
+ ],
+ tags: ['users']
+ delete do
+ status 204
+ end
+ end
+ end
+
+ resource :files do
+ desc 'Upload a file',
+ consumes: ['multipart/form-data'],
+ tags: ['files']
+ params do
+ requires :file, type: File, desc: 'File to upload'
+ optional :description, type: String, desc: 'File description'
+ end
+ post :upload do
+ { uploaded: true }
+ end
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.0',
+ info: {
+ title: 'Integration Test API',
+ description: 'A comprehensive API for integration testing',
+ version: '1.0.0',
+ contact: {
+ name: 'API Support',
+ email: 'support@example.com'
+ },
+ license: {
+ name: 'MIT',
+ url: 'https://opensource.org/licenses/MIT'
+ }
+ },
+ host: 'api.example.com',
+ base_path: '/api/v1',
+ tags: [
+ { name: 'users', description: 'User management operations' },
+ { name: 'files', description: 'File operations' }
+ ],
+ security_definitions: {
+ bearer: {
+ type: 'apiKey',
+ name: 'Authorization',
+ in: 'header',
+ description: 'Bearer token authentication'
+ }
+ },
+ security: [{ bearer: [] }]
+ )
+ end
+ end
+ end
+
+ def app
+ IntegrationTest::API
+ end
+
+ subject do
+ get '/api/v1/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'API structure' do
+ it 'has correct openapi version' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'has info section with all fields' do
+ info = subject['info']
+ expect(info['title']).to eq('Integration Test API')
+ expect(info['description']).to include('comprehensive API')
+ expect(info['version']).to be_present
+ expect(info['license']['name']).to eq('MIT')
+ end
+
+ # NOTE: contact info requires contact_name, contact_email, contact_url options
+ # The nested hash format under info: { contact: {} } may not be fully supported
+
+ it 'has servers section' do
+ expect(subject['servers']).to be_an(Array)
+ expect(subject['servers'].first['url']).to include('api.example.com')
+ end
+
+ it 'has tags section' do
+ tags = subject['tags']
+ expect(tags.map { |t| t['name'] }).to include('users', 'files')
+ end
+
+ it 'has security at root level' do
+ expect(subject['security']).to eq([{ 'bearer' => [] }])
+ end
+
+ it 'has components with securitySchemes' do
+ schemes = subject['components']['securitySchemes']
+ expect(schemes).to have_key('bearer')
+ expect(schemes['bearer']['type']).to eq('apiKey')
+ end
+ end
+
+ describe 'paths structure' do
+ it 'has all expected paths' do
+ paths = subject['paths'].keys
+ expect(paths).to include('/api/v1/users')
+ expect(paths).to include('/api/v1/users/{id}')
+ expect(paths).to include('/api/v1/files/upload')
+ end
+
+ describe 'GET /users' do
+ let(:operation) { subject['paths']['/api/v1/users']['get'] }
+
+ it 'has correct operation structure' do
+ expect(operation['tags']).to include('users')
+ expect(operation['parameters']).to be_an(Array)
+ end
+
+ it 'has query parameters with schema wrapper' do
+ page_param = operation['parameters'].find { |p| p['name'] == 'page' }
+ expect(page_param['in']).to eq('query')
+ expect(page_param['schema']).to have_key('type')
+ expect(page_param['schema']['type']).to eq('integer')
+ expect(page_param['schema']['default']).to eq(1)
+ end
+
+ it 'has enum parameter' do
+ role_param = operation['parameters'].find { |p| p['name'] == 'role' }
+ expect(role_param['schema']['enum']).to eq(%w[admin user guest])
+ end
+
+ it 'has responses with content wrapper' do
+ response_200 = operation['responses']['200']
+ expect(response_200['content']).to have_key('application/json')
+ expect(response_200['content']['application/json']['schema']).to have_key('$ref')
+ end
+
+ it 'has error responses' do
+ expect(operation['responses']).to have_key('401')
+ expect(operation['responses']).to have_key('403')
+ end
+ end
+
+ describe 'POST /users' do
+ let(:operation) { subject['paths']['/api/v1/users']['post'] }
+
+ it 'has requestBody instead of body parameter' do
+ expect(operation['requestBody']).to be_present
+ expect(operation['parameters']&.any? { |p| p['in'] == 'body' }).to be_falsey
+ end
+
+ it 'has requestBody with content' do
+ content = operation['requestBody']['content']
+ expect(content).to have_key('application/json')
+ end
+
+ it 'marks requestBody as required' do
+ expect(operation['requestBody']['required']).to be true
+ end
+ end
+
+ describe 'GET /users/{id}' do
+ let(:operation) { subject['paths']['/api/v1/users/{id}']['get'] }
+
+ it 'has path parameter with schema' do
+ id_param = operation['parameters'].find { |p| p['name'] == 'id' }
+ expect(id_param['in']).to eq('path')
+ expect(id_param['required']).to be true
+ expect(id_param['schema']['type']).to eq('integer')
+ end
+ end
+
+ describe 'DELETE /users/{id}' do
+ let(:operation) { subject['paths']['/api/v1/users/{id}']['delete'] }
+
+ it 'has no requestBody' do
+ expect(operation['requestBody']).to be_nil
+ end
+ end
+
+ describe 'POST /files/upload' do
+ let(:operation) { subject['paths']['/api/v1/files/upload']['post'] }
+
+ it 'uses multipart/form-data for file upload' do
+ content = operation['requestBody']['content']
+ expect(content).to have_key('multipart/form-data')
+ end
+
+ it 'has file field with binary format' do
+ schema = operation['requestBody']['content']['multipart/form-data']['schema']
+ file_prop = schema['properties']['file']
+ expect(file_prop['type']).to eq('string')
+ expect(file_prop['format']).to eq('binary')
+ end
+ end
+ end
+
+ describe 'components/schemas' do
+ let(:schemas) { subject['components']['schemas'] }
+
+ it 'has all entity schemas' do
+ schema_names = schemas.keys
+ expect(schema_names.any? { |n| n.include?('User') }).to be true
+ expect(schema_names.any? { |n| n.include?('Error') }).to be true
+ expect(schema_names.any? { |n| n.include?('Address') }).to be true
+ end
+
+ it 'uses $ref for nested entities' do
+ user_schema = schemas.find { |name, _| name.include?('User') && !name.include?('List') && !name.include?('Request') }
+ if user_schema
+ props = user_schema[1]['properties']
+ expect(props['address']['$ref'] || props['address']['description']).to be_present if props && props['address']
+ end
+ end
+ end
+
+ describe 'no Swagger 2.0 artifacts' do
+ it 'does not have swagger key' do
+ expect(subject).not_to have_key('swagger')
+ end
+
+ it 'does not have definitions at root' do
+ expect(subject).not_to have_key('definitions')
+ end
+
+ it 'does not have securityDefinitions at root' do
+ expect(subject).not_to have_key('securityDefinitions')
+ end
+
+ it 'does not have host/basePath/schemes at root' do
+ expect(subject).not_to have_key('host')
+ expect(subject).not_to have_key('basePath')
+ expect(subject).not_to have_key('schemes')
+ end
+
+ it 'does not have produces/consumes at root' do
+ expect(subject).not_to have_key('produces')
+ expect(subject).not_to have_key('consumes')
+ end
+
+ it 'uses #/components/schemas refs not #/definitions' do
+ json_string = subject.to_json
+ expect(json_string).not_to include('#/definitions/')
+ expect(json_string).to include('#/components/schemas/')
+ end
+ end
+ end
+
+ describe 'API with mixed parameter types' do
+ before :all do
+ module MixedParamsTest
+ class API < Grape::API
+ format :json
+
+ desc 'Mixed parameters endpoint'
+ params do
+ requires :id, type: Integer, desc: 'Resource ID'
+ requires :name, type: String, desc: 'Name in body'
+ optional :include, type: Array[String], desc: 'Relations to include'
+ end
+ put '/resources/:id' do
+ { id: params[:id], name: params[:name] }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ MixedParamsTest::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'separates path and body parameters correctly' do
+ operation = subject['paths']['/resources/{id}']['put']
+
+ # Path parameter should be in parameters array
+ path_params = operation['parameters'].select { |p| p['in'] == 'path' }
+ expect(path_params.length).to eq(1)
+ expect(path_params.first['name']).to eq('id')
+
+ # Body parameters should be in requestBody
+ expect(operation['requestBody']).to be_present
+ end
+ end
+
+ describe 'OAS 3.1 vs 3.0 differences' do
+ before :all do
+ module VersionDiffTest
+ module Entities
+ class Item < Grape::Entity
+ expose :id, documentation: { type: Integer, required: true }
+ expose :name, documentation: { type: String, required: true }
+ expose :optional_field, documentation: { type: String, x: { nullable: true } }
+ end
+ end
+
+ class API30 < Grape::API
+ format :json
+ desc 'Get item', success: Entities::Item
+ get('/item') { {} }
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+
+ class API31 < Grape::API
+ format :json
+ desc 'Get item', success: Entities::Item
+ get('/item') { {} }
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ info: { license: { name: 'MIT', identifier: 'MIT' } }
+ )
+ end
+ end
+ end
+
+ describe 'OpenAPI 3.0' do
+ def app
+ VersionDiffTest::API30
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'uses openapi 3.0.3' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'does not have license identifier' do
+ # 3.0 doesn't support identifier
+ license = subject['info']['license']
+ expect(license).to be_nil.or(satisfy { |l| !l.key?('identifier') })
+ end
+ end
+
+ describe 'OpenAPI 3.1' do
+ def app
+ VersionDiffTest::API31
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'uses openapi 3.1.0' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+
+ it 'has license identifier' do
+ expect(subject['info']['license']['identifier']).to eq('MIT')
+ end
+
+ it 'does not have nullable keyword' do
+ json_string = subject.to_json
+ expect(json_string).not_to include('"nullable":true')
+ expect(json_string).not_to include('"nullable": true')
+ end
+ end
+ end
+end
diff --git a/spec/openapi_v3/links_callbacks_spec.rb b/spec/openapi_v3/links_callbacks_spec.rb
new file mode 100644
index 00000000..d2a0253a
--- /dev/null
+++ b/spec/openapi_v3/links_callbacks_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Links and Callbacks in OpenAPI 3.0' do
+ describe 'Response links' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ let(:response_with_links) do
+ GrapeSwagger::OpenAPI::Response.new.tap do |r|
+ r.description = 'Successful response'
+ r.links = {
+ 'GetUserById' => {
+ 'operationId' => 'getUser',
+ 'parameters' => {
+ 'userId' => '$response.body#/id'
+ }
+ }
+ }
+ end
+ end
+
+ let(:operation) do
+ GrapeSwagger::OpenAPI::Operation.new.tap do |op|
+ op.operation_id = 'createUser'
+ op.add_response('201', response_with_links)
+ end
+ end
+
+ let(:path_item) do
+ GrapeSwagger::OpenAPI::PathItem.new.tap do |pi|
+ pi.add_operation(:post, operation)
+ end
+ end
+
+ before do
+ spec.paths['/users'] = path_item
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports links in response' do
+ response = subject[:paths]['/users'][:post][:responses]['201']
+ expect(response[:links]).to be_present
+ end
+
+ it 'includes link with operationId' do
+ links = subject[:paths]['/users'][:post][:responses]['201'][:links]
+ expect(links['GetUserById']['operationId']).to eq('getUser')
+ end
+
+ it 'includes link parameters with runtime expressions' do
+ links = subject[:paths]['/users'][:post][:responses]['201'][:links]
+ expect(links['GetUserById']['parameters']['userId']).to eq('$response.body#/id')
+ end
+ end
+
+ describe 'Operation callbacks' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ let(:operation_with_callbacks) do
+ GrapeSwagger::OpenAPI::Operation.new.tap do |op|
+ op.operation_id = 'createSubscription'
+ op.callbacks = {
+ 'onData' => {
+ '{$request.body#/callbackUrl}' => {
+ 'post' => {
+ 'requestBody' => {
+ 'content' => {
+ 'application/json' => {
+ 'schema' => { 'type' => 'object' }
+ }
+ }
+ },
+ 'responses' => {
+ '200' => { 'description' => 'Callback processed' }
+ }
+ }
+ }
+ }
+ }
+ op.add_response('201', GrapeSwagger::OpenAPI::Response.new(description: 'Created'))
+ end
+ end
+
+ let(:path_item) do
+ GrapeSwagger::OpenAPI::PathItem.new.tap do |pi|
+ pi.add_operation(:post, operation_with_callbacks)
+ end
+ end
+
+ before do
+ spec.paths['/subscriptions'] = path_item
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports callbacks in operation' do
+ operation = subject[:paths]['/subscriptions'][:post]
+ expect(operation[:callbacks]).to be_present
+ end
+
+ it 'includes callback name' do
+ callbacks = subject[:paths]['/subscriptions'][:post][:callbacks]
+ expect(callbacks).to have_key('onData')
+ end
+
+ it 'includes callback URL expression' do
+ callbacks = subject[:paths]['/subscriptions'][:post][:callbacks]
+ expect(callbacks['onData']).to have_key('{$request.body#/callbackUrl}')
+ end
+
+ it 'includes callback operation' do
+ callback_path = subject[:paths]['/subscriptions'][:post][:callbacks]['onData']['{$request.body#/callbackUrl}']
+ expect(callback_path).to have_key('post')
+ expect(callback_path['post']['responses']['200']['description']).to eq('Callback processed')
+ end
+ end
+
+ describe 'Components links' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ before do
+ spec.components.links['GetUserById'] = {
+ 'operationId' => 'getUser',
+ 'parameters' => { 'userId' => '$response.body#/id' },
+ 'description' => 'Get the user by ID'
+ }
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports links in components' do
+ expect(subject[:components][:links]).to be_present
+ end
+
+ it 'includes link definition' do
+ expect(subject[:components][:links]['GetUserById']['operationId']).to eq('getUser')
+ end
+ end
+
+ describe 'Components callbacks' do
+ let(:spec) do
+ GrapeSwagger::OpenAPI::Document.new.tap do |s|
+ s.info = GrapeSwagger::OpenAPI::Info.new(title: 'Test', version: '1.0')
+ end
+ end
+
+ before do
+ spec.components.callbacks['onWebhook'] = {
+ '{$request.body#/webhookUrl}' => {
+ 'post' => {
+ 'summary' => 'Webhook notification',
+ 'responses' => {
+ '200' => { 'description' => 'OK' }
+ }
+ }
+ }
+ }
+ end
+
+ subject { GrapeSwagger::Exporter::OAS30.new(spec).export }
+
+ it 'exports callbacks in components' do
+ expect(subject[:components][:callbacks]).to be_present
+ end
+
+ it 'includes callback definition' do
+ expect(subject[:components][:callbacks]['onWebhook']).to have_key('{$request.body#/webhookUrl}')
+ end
+ end
+end
diff --git a/spec/openapi_v3/nested_entities_spec.rb b/spec/openapi_v3/nested_entities_spec.rb
new file mode 100644
index 00000000..5648b426
--- /dev/null
+++ b/spec/openapi_v3/nested_entities_spec.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Nested entities in OpenAPI 3.0' do
+ before :all do
+ module NestedEntitiesOAS3
+ module Entities
+ class ResponseItem < Grape::Entity
+ expose :id, documentation: { type: Integer, desc: 'Item ID', required: true }
+ expose :name, documentation: { type: String, desc: 'Item name', required: true }
+ end
+
+ class UseResponse < Grape::Entity
+ expose :description, documentation: { type: String, desc: 'Description', required: true }
+ expose :items, documentation: { type: ResponseItem, is_array: true, desc: 'Items', required: true }
+ end
+
+ class ThirdLevel < Grape::Entity
+ expose :text, documentation: { type: String, desc: 'Text', required: true }
+ end
+
+ class SecondLevel < Grape::Entity
+ expose :parts, documentation: { type: ThirdLevel, desc: 'Parts', required: true }
+ end
+
+ class FirstLevel < Grape::Entity
+ expose :parts, documentation: { type: SecondLevel, desc: 'Parts', required: true }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+
+ desc 'Get nested response',
+ success: Entities::UseResponse
+ get '/nested' do
+ { description: 'test', items: [{ id: 1, name: 'item' }] }
+ end
+
+ desc 'Get deeply nested response',
+ success: Entities::FirstLevel
+ get '/deep_nested' do
+ { parts: { parts: { parts: { text: 'deep' } } } }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ NestedEntitiesOAS3::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'schema references' do
+ it 'uses components/schemas path for refs' do
+ json_string = subject.to_json
+ expect(json_string).to include('#/components/schemas/')
+ expect(json_string).not_to include('#/definitions/')
+ end
+
+ it 'places entity schemas in components/schemas' do
+ schemas = subject['components']['schemas']
+ # Schema names may include module path
+ expect(schemas.keys.any? { |k| k.include?('UseResponse') }).to be true
+ end
+
+ it 'places response entity schemas in components/schemas' do
+ schemas = subject['components']['schemas']
+ expect(schemas.keys.any? { |k| k.include?('FirstLevel') }).to be true
+ end
+ end
+
+ describe 'response schema reference' do
+ let(:nested_response) { subject['paths']['/nested']['get']['responses']['200'] }
+
+ it 'references schema in content' do
+ content = nested_response['content']['application/json']
+ expect(content['schema']['$ref']).to match(%r{#/components/schemas/.*UseResponse})
+ end
+ end
+
+ describe 'deeply nested response' do
+ let(:deep_response) { subject['paths']['/deep_nested']['get']['responses']['200'] }
+
+ it 'references first level schema in response' do
+ content = deep_response['content']['application/json']
+ expect(content['schema']['$ref']).to match(%r{#/components/schemas/.*FirstLevel})
+ end
+ end
+end
+
+describe 'Reference path conversion' do
+ it 'converts definitions refs to components/schemas refs' do
+ schema = GrapeSwagger::OpenAPI::Schema.new
+ schema.canonical_name = 'TestModel'
+
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('TestModel', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ # The schema should export as a reference
+ exported_schema = output[:components][:schemas]['TestModel']
+ expect(exported_schema).to eq({ '$ref' => '#/components/schemas/TestModel' })
+ end
+
+ it 'converts inline refs in hash schemas' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+
+ # Simulate a hash with Swagger 2.0 style ref
+ hash_schema = { '$ref' => '#/definitions/SomeModel' }
+ result = exporter.send(:export_hash_schema, hash_schema)
+
+ expect(result['$ref']).to eq('#/components/schemas/SomeModel')
+ end
+end
diff --git a/spec/openapi_v3/null_type_spec.rb b/spec/openapi_v3/null_type_spec.rb
new file mode 100644
index 00000000..a2e6b933
--- /dev/null
+++ b/spec/openapi_v3/null_type_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OpenAPI 3.1 type: null support' do
+ describe 'entity with null type property using string type' do
+ before :all do
+ module NullTypeStringTest
+ module Entities
+ class NullableItem < Grape::Entity
+ expose :id, documentation: { type: Integer, required: true }
+ expose :name, documentation: { type: String, required: true }
+ # A property that is always null (e.g., placeholder for future use)
+ expose :deprecated_field, documentation: { type: 'null', desc: 'This field is always null' }
+ end
+ end
+
+ class API31 < Grape::API
+ format :json
+ desc 'Get item', success: Entities::NullableItem
+ get('/item') { {} }
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+
+ class API30 < Grape::API
+ format :json
+ desc 'Get item', success: Entities::NullableItem
+ get('/item') { {} }
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ describe 'OpenAPI 3.1' do
+ def app
+ NullTypeStringTest::API31
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ let(:schema) do
+ subject['components']['schemas'].find { |name, _| name.include?('NullableItem') }&.last
+ end
+
+ it 'has openapi 3.1.0 version' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+
+ it 'has type: null for deprecated_field' do
+ expect(schema['properties']['deprecated_field']['type']).to eq('null')
+ end
+
+ it 'preserves description for null type property' do
+ expect(schema['properties']['deprecated_field']['description']).to eq('This field is always null')
+ end
+ end
+
+ describe 'OpenAPI 3.0' do
+ def app
+ NullTypeStringTest::API30
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ let(:schema) do
+ subject['components']['schemas'].find { |name, _| name.include?('NullableItem') }&.last
+ end
+
+ it 'has openapi 3.0.3 version' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'converts null type to nullable in OAS 3.0' do
+ # OAS 3.0 doesn't support type: null, so we use nullable: true
+ deprecated_field = schema['properties']['deprecated_field']
+ expect(deprecated_field['nullable']).to be true
+ expect(deprecated_field['type']).to be_nil
+ end
+ end
+ end
+
+ describe 'parameter with null type in grape params' do
+ before :all do
+ module NullParamGrapeTest
+ class API31 < Grape::API
+ format :json
+
+ desc 'Endpoint with various param types'
+ params do
+ requires :id, type: Integer, desc: 'ID field'
+ optional :name, type: String, desc: 'Name field'
+ end
+ post '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ NullParamGrapeTest::API31
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'generates valid OpenAPI 3.1 output' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+
+ it 'has request body with proper types' do
+ operation = subject['paths']['/test']['post']
+ expect(operation['requestBody']).to be_present
+ end
+ end
+
+ describe 'null type in mixed schema definitions' do
+ before :all do
+ module NullTypeMixedTest
+ module Entities
+ class MixedItem < Grape::Entity
+ expose :id, documentation: { type: Integer, required: true }
+ expose :string_field, documentation: { type: String }
+ expose :null_field, documentation: { type: 'null', desc: 'Always null' }
+ expose :number_field, documentation: { type: Float }
+ end
+ end
+
+ class API31 < Grape::API
+ format :json
+ desc 'Get mixed', success: Entities::MixedItem
+ get('/mixed') { {} }
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ NullTypeMixedTest::API31
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ let(:schema) do
+ subject['components']['schemas'].find { |name, _| name.include?('MixedItem') }&.last
+ end
+
+ it 'preserves other types alongside null type' do
+ expect(schema['properties']['string_field']['type']).to eq('string')
+ expect(schema['properties']['number_field']['type']).to eq('number')
+ end
+
+ it 'has type: null for null_field' do
+ expect(schema['properties']['null_field']['type']).to eq('null')
+ end
+
+ it 'has all expected properties' do
+ expect(schema['properties'].keys).to include('id', 'string_field', 'null_field', 'number_field')
+ end
+ end
+end
diff --git a/spec/openapi_v3/nullable_fields_spec.rb b/spec/openapi_v3/nullable_fields_spec.rb
new file mode 100644
index 00000000..b3703f7c
--- /dev/null
+++ b/spec/openapi_v3/nullable_fields_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Nullable fields' do
+ describe 'OpenAPI 3.0' do
+ before :all do
+ module TheApi
+ class NullableOAS30Api < Grape::API
+ format :json
+
+ desc 'Create with nullable fields'
+ params do
+ requires :name, type: String, desc: 'Required name'
+ optional :nickname, type: String, allow_blank: true, desc: 'Optional nullable nickname'
+ end
+ post '/create' do
+ { id: 1 }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ TheApi::NullableOAS30Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ let(:schema) { subject['components']['schemas']['postCreate'] }
+
+ it 'generates OpenAPI 3.0.3 version' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'includes both fields in schema properties' do
+ expect(schema['properties']).to have_key('name')
+ expect(schema['properties']).to have_key('nickname')
+ end
+ end
+
+ describe 'OpenAPI 3.1' do
+ before :all do
+ module TheApi
+ class NullableOAS31Api < Grape::API
+ format :json
+
+ desc 'Create with nullable fields'
+ params do
+ requires :name, type: String, desc: 'Required name'
+ optional :nickname, type: String, allow_blank: true, desc: 'Optional nullable nickname'
+ end
+ post '/create' do
+ { id: 1 }
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ TheApi::NullableOAS31Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ let(:schema) { subject['components']['schemas']['postCreate'] }
+
+ it 'generates OpenAPI 3.1.0 version' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+
+ it 'includes both fields in schema properties' do
+ expect(schema['properties']).to have_key('name')
+ expect(schema['properties']).to have_key('nickname')
+ end
+ end
+
+ describe 'OAS 3.0 vs 3.1 nullable handling' do
+ # NOTE: This tests the structural difference in how nullable is represented
+ # OAS 3.0: uses "nullable: true"
+ # OAS 3.1: uses type array like ["string", "null"]
+
+ it 'OAS 3.0 exporter uses nullable_keyword' do
+ exporter = GrapeSwagger::Exporter::OAS30.new(GrapeSwagger::OpenAPI::Document.new)
+ expect(exporter.send(:nullable_keyword?)).to be true
+ end
+
+ it 'OAS 3.1 exporter does not use nullable_keyword' do
+ exporter = GrapeSwagger::Exporter::OAS31.new(GrapeSwagger::OpenAPI::Document.new)
+ expect(exporter.send(:nullable_keyword?)).to be false
+ end
+
+ it 'exports nullable schema correctly in OAS 3.0' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:nullable]).to eq(true)
+ expect(output[:components][:schemas]['Test'][:type]).to eq('string')
+ end
+
+ it 'exports nullable schema correctly in OAS 3.1' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:type]).to eq(%w[string null])
+ expect(output[:components][:schemas]['Test']).not_to have_key(:nullable)
+ end
+ end
+end
diff --git a/spec/openapi_v3/nullable_handling_spec.rb b/spec/openapi_v3/nullable_handling_spec.rb
new file mode 100644
index 00000000..12152c02
--- /dev/null
+++ b/spec/openapi_v3/nullable_handling_spec.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Nullable handling in OAS 3.0 vs 3.1' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ describe 'OAS 3.0 nullable via documentation option' do
+ before :all do
+ module NullableTest30
+ module Entities
+ class Item < Grape::Entity
+ expose :id, documentation: { type: Integer, desc: 'ID' }
+ expose :name, documentation: { type: String, desc: 'Name' }
+ expose :nickname, documentation: { type: String, desc: 'Optional nickname', nullable: true }
+ expose :description, documentation: { type: String, desc: 'Description', x: { nullable: true } }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+
+ desc 'Create item',
+ success: { code: 201, model: Entities::Item }
+ params do
+ requires :name, type: String, desc: 'Name'
+ optional :nickname, type: String, documentation: { nullable: true }, desc: 'Nullable nickname'
+ optional :age, type: Integer, allow_blank: true, desc: 'Optional age (allow_blank)'
+ # Backward compatible: x: { nullable: true } should also work
+ optional :alias, type: String, documentation: { x: { nullable: true } }, desc: 'Nullable via x extension'
+ end
+ post '/items' do
+ present({}, with: Entities::Item)
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ NullableTest30::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'request body field with documentation: { nullable: true }' do
+ let(:request_schema) { subject['components']['schemas']['postItems'] }
+
+ it 'marks field as nullable in request schema' do
+ nickname = request_schema['properties']['nickname']
+ expect(nickname['nullable']).to eq(true)
+ end
+
+ it 'marks field with x: { nullable: true } as nullable (backward compatibility)' do
+ alias_field = request_schema['properties']['alias']
+ expect(alias_field['nullable']).to eq(true)
+ end
+ end
+ end
+
+ describe 'OAS 3.1 nullable handling' do
+ before :all do
+ module NullableTest31
+ module Entities
+ class Item < Grape::Entity
+ expose :id, documentation: { type: Integer, desc: 'ID' }
+ expose :name, documentation: { type: String, desc: 'Name' }
+ expose :nickname, documentation: { type: String, desc: 'Optional nickname', nullable: true }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+
+ desc 'Create item',
+ success: { code: 201, model: Entities::Item }
+ params do
+ requires :name, type: String, desc: 'Name'
+ optional :nickname, type: String, documentation: { nullable: true }, desc: 'Nullable nickname'
+ # Backward compatible: x: { nullable: true } should also work
+ optional :alias, type: String, documentation: { x: { nullable: true } }, desc: 'Nullable via x extension'
+ end
+ post '/items' do
+ present({}, with: Entities::Item)
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ NullableTest31::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'request body field with documentation: { nullable: true }' do
+ let(:request_schema) { subject['components']['schemas']['postItems'] }
+
+ it 'uses type array for nullable in request schema' do
+ nickname = request_schema['properties']['nickname']
+ expect(nickname['type']).to eq(%w[string null])
+ expect(nickname).not_to have_key('nullable')
+ end
+
+ it 'uses type array for x: { nullable: true } (backward compatibility)' do
+ alias_field = request_schema['properties']['alias']
+ expect(alias_field['type']).to eq(%w[string null])
+ expect(alias_field).not_to have_key('nullable')
+ end
+ end
+ end
+
+ describe 'Direct exporter tests' do
+ it 'OAS 3.0: converts schema.nullable to nullable: true' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:nullable]).to eq(true)
+ expect(output[:components][:schemas]['Test'][:type]).to eq('string')
+ end
+
+ it 'OAS 3.1: converts schema.nullable to type array' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:type]).to eq(%w[string null])
+ expect(output[:components][:schemas]['Test']).not_to have_key(:nullable)
+ end
+
+ it 'OAS 3.0: nested property with nullable' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'object')
+ schema.add_property('name', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ nullable_prop = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ schema.add_property('nickname', nullable_prop)
+
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:properties]['nickname'][:nullable]).to eq(true)
+ end
+
+ it 'OAS 3.1: nested property with nullable' do
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'object')
+ schema.add_property('name', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ nullable_prop = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ schema.add_property('nickname', nullable_prop)
+
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:properties]['nickname'][:type]).to eq(%w[string null])
+ expect(output[:components][:schemas]['Test'][:properties]['nickname']).not_to have_key(:nullable)
+ end
+
+ it 'OAS 3.0: array items with nullable' do
+ items_schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'array', items: items_schema)
+
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:items][:nullable]).to eq(true)
+ end
+
+ it 'OAS 3.1: array items with nullable' do
+ items_schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string', nullable: true)
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'array', items: items_schema)
+
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.components.add_schema('Test', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['Test'][:items][:type]).to eq(%w[string null])
+ end
+ end
+end
diff --git a/spec/openapi_v3/oas31_configuration_spec.rb b/spec/openapi_v3/oas31_configuration_spec.rb
new file mode 100644
index 00000000..42589281
--- /dev/null
+++ b/spec/openapi_v3/oas31_configuration_spec.rb
@@ -0,0 +1,363 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OpenAPI 3.1 Configuration Options' do
+ describe 'json_schema_dialect option' do
+ before :all do
+ module JsonSchemaDialectTest
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ json_schema_dialect: 'https://json-schema.org/draft/2020-12/schema'
+ )
+ end
+ end
+ end
+
+ def app
+ JsonSchemaDialectTest::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'includes jsonSchemaDialect in output' do
+ expect(subject['jsonSchemaDialect']).to eq('https://json-schema.org/draft/2020-12/schema')
+ end
+
+ it 'places jsonSchemaDialect after openapi version' do
+ keys = subject.keys
+ expect(keys.index('jsonSchemaDialect')).to be < keys.index('info')
+ end
+ end
+
+ describe 'json_schema_dialect ignored in OAS 3.0' do
+ before :all do
+ module JsonSchemaDialect30Test
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.0',
+ json_schema_dialect: 'https://json-schema.org/draft/2020-12/schema'
+ )
+ end
+ end
+ end
+
+ def app
+ JsonSchemaDialect30Test::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'does not include jsonSchemaDialect in OAS 3.0' do
+ expect(subject).not_to have_key('jsonSchemaDialect')
+ end
+ end
+
+ describe 'webhooks option' do
+ before :all do
+ module WebhooksTest
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ webhooks: {
+ newPetAvailable: {
+ post: {
+ summary: 'New pet available',
+ description: 'A new pet has been added to the store',
+ operationId: 'newPetWebhook',
+ tags: ['pets'],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ petId: { type: 'integer', description: 'Pet ID' },
+ petName: { type: 'string', description: 'Pet name' }
+ },
+ required: %w[petId petName]
+ }
+ }
+ }
+ },
+ responses: {
+ '200': { description: 'Webhook received successfully' },
+ '400': { description: 'Invalid payload' }
+ }
+ }
+ },
+ orderStatusChanged: {
+ post: {
+ summary: 'Order status changed',
+ description: 'An order status has been updated',
+ responses: {
+ '200': { description: 'OK' }
+ }
+ }
+ }
+ }
+ )
+ end
+ end
+ end
+
+ def app
+ WebhooksTest::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'includes webhooks section' do
+ expect(subject).to have_key('webhooks')
+ end
+
+ it 'has all defined webhooks' do
+ webhooks = subject['webhooks']
+ expect(webhooks).to have_key('newPetAvailable')
+ expect(webhooks).to have_key('orderStatusChanged')
+ end
+
+ describe 'newPetAvailable webhook' do
+ let(:webhook) { subject['webhooks']['newPetAvailable'] }
+ let(:operation) { webhook['post'] }
+
+ it 'has correct summary' do
+ expect(operation['summary']).to eq('New pet available')
+ end
+
+ it 'has correct description' do
+ expect(operation['description']).to eq('A new pet has been added to the store')
+ end
+
+ it 'has operationId' do
+ expect(operation['operationId']).to eq('newPetWebhook')
+ end
+
+ it 'has tags' do
+ expect(operation['tags']).to eq(['pets'])
+ end
+
+ it 'has requestBody' do
+ expect(operation['requestBody']).to be_present
+ expect(operation['requestBody']['required']).to be true
+ end
+
+ it 'has requestBody content with schema' do
+ content = operation['requestBody']['content']['application/json']
+ expect(content['schema']['type']).to eq('object')
+ expect(content['schema']['properties']).to have_key('petId')
+ expect(content['schema']['properties']).to have_key('petName')
+ end
+
+ it 'has responses' do
+ expect(operation['responses']).to have_key('200')
+ expect(operation['responses']).to have_key('400')
+ end
+ end
+
+ describe 'orderStatusChanged webhook' do
+ let(:webhook) { subject['webhooks']['orderStatusChanged'] }
+ let(:operation) { webhook['post'] }
+
+ it 'has correct summary' do
+ expect(operation['summary']).to eq('Order status changed')
+ end
+
+ it 'has responses' do
+ expect(operation['responses']['200']['description']).to eq('OK')
+ end
+ end
+ end
+
+ describe 'webhooks ignored in OAS 3.0' do
+ before :all do
+ module Webhooks30Test
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.0',
+ webhooks: {
+ testWebhook: {
+ post: {
+ summary: 'Test webhook',
+ responses: { '200': { description: 'OK' } }
+ }
+ }
+ }
+ )
+ end
+ end
+ end
+
+ def app
+ Webhooks30Test::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'does not include webhooks in OAS 3.0' do
+ expect(subject).not_to have_key('webhooks')
+ end
+ end
+
+ describe 'webhooks with schema reference' do
+ before :all do
+ module WebhooksRefTest
+ module Entities
+ class Pet < Grape::Entity
+ expose :id, documentation: { type: Integer, required: true }
+ expose :name, documentation: { type: String, required: true }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+
+ desc 'Get pet', success: Entities::Pet
+ get '/pet' do
+ { id: 1, name: 'Fluffy' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ webhooks: {
+ petCreated: {
+ post: {
+ summary: 'Pet created',
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: { '$ref': '#/components/schemas/Pet' }
+ }
+ }
+ },
+ responses: {
+ '200': { description: 'OK' }
+ }
+ }
+ }
+ }
+ )
+ end
+ end
+ end
+
+ def app
+ WebhooksRefTest::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'includes webhook with schema reference' do
+ webhook = subject['webhooks']['petCreated']
+ content = webhook['post']['requestBody']['content']['application/json']
+ expect(content['schema']['$ref']).to eq('#/components/schemas/Pet')
+ end
+ end
+
+ describe 'combined OAS 3.1 options' do
+ before :all do
+ module CombinedOAS31Test
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ { status: 'ok' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ info: {
+ title: 'Combined Test API',
+ license: { name: 'MIT', identifier: 'MIT' }
+ },
+ json_schema_dialect: 'https://json-schema.org/draft/2020-12/schema',
+ webhooks: {
+ testEvent: {
+ post: {
+ summary: 'Test event',
+ responses: { '200': { description: 'OK' } }
+ }
+ }
+ }
+ )
+ end
+ end
+ end
+
+ def app
+ CombinedOAS31Test::API
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has openapi 3.1.0 version' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+
+ it 'has jsonSchemaDialect' do
+ expect(subject['jsonSchemaDialect']).to eq('https://json-schema.org/draft/2020-12/schema')
+ end
+
+ it 'has webhooks' do
+ expect(subject['webhooks']).to have_key('testEvent')
+ end
+
+ it 'has license with identifier' do
+ expect(subject['info']['license']['identifier']).to eq('MIT')
+ end
+
+ it 'does not have license url when identifier is present' do
+ expect(subject['info']['license']).not_to have_key('url')
+ end
+ end
+end
diff --git a/spec/openapi_v3/oas31_features_spec.rb b/spec/openapi_v3/oas31_features_spec.rb
new file mode 100644
index 00000000..faebc1c5
--- /dev/null
+++ b/spec/openapi_v3/oas31_features_spec.rb
@@ -0,0 +1,249 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OpenAPI 3.1 specific features' do
+ before :all do
+ module TheApi
+ class OAS31FeaturesApi < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/items' do
+ []
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ TheApi::OAS31FeaturesApi
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'openapi version' do
+ it 'uses 3.1.0 version string' do
+ expect(subject['openapi']).to eq('3.1.0')
+ end
+ end
+
+ describe 'nullable types' do
+ before :all do
+ module OAS31NullableApi
+ module Entities
+ class NullableItem < Grape::Entity
+ expose :name, documentation: { type: String, desc: 'Name' }
+ expose :description, documentation: { type: String, desc: 'Optional description', x: { nullable: true } }
+ end
+ end
+
+ class API < Grape::API
+ format :json
+
+ desc 'Get nullable item',
+ entity: Entities::NullableItem
+ get '/nullable_item' do
+ present OpenStruct.new(name: 'test'), with: Entities::NullableItem
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ OAS31NullableApi::API
+ end
+
+ it 'uses type array for nullable instead of nullable keyword' do
+ get '/swagger_doc'
+ json = JSON.parse(last_response.body)
+
+ # OAS 3.1 should NOT use nullable keyword
+ json_string = json.to_json
+ expect(json_string).not_to include('"nullable":true')
+ expect(json_string).not_to include('"nullable": true')
+ end
+ end
+
+ describe 'license with identifier' do
+ before :all do
+ module OAS31LicenseApi
+ class API < Grape::API
+ format :json
+
+ desc 'Simple endpoint'
+ get '/test' do
+ {}
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.1',
+ info: {
+ license: {
+ name: 'MIT',
+ identifier: 'MIT'
+ }
+ }
+ )
+ end
+ end
+ end
+
+ def app
+ OAS31LicenseApi::API
+ end
+
+ it 'includes identifier in license' do
+ get '/swagger_doc'
+ json = JSON.parse(last_response.body)
+
+ expect(json['info']['license']['name']).to eq('MIT')
+ expect(json['info']['license']['identifier']).to eq('MIT')
+ end
+
+ it 'does not include url when identifier is present' do
+ get '/swagger_doc'
+ json = JSON.parse(last_response.body)
+
+ expect(json['info']['license']).not_to have_key('url')
+ end
+ end
+end
+
+describe 'OpenAPI 3.1 webhooks' do
+ describe 'manual webhook configuration' do
+ it 'exports webhooks in OAS 3.1 format' do
+ # Create API Model manually to test webhooks export
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Webhook Test API'
+ spec.info.version = '1.0'
+
+ # Create a webhook path item
+ webhook_path = GrapeSwagger::OpenAPI::PathItem.new
+ webhook_op = GrapeSwagger::OpenAPI::Operation.new
+ webhook_op.summary = 'New pet notification'
+ webhook_op.description = 'Receives notification when a new pet is added'
+
+ request_body = GrapeSwagger::OpenAPI::RequestBody.new
+ request_body.required = true
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'object')
+ schema.add_property('petName', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+ request_body.add_media_type('application/json', schema: schema)
+ webhook_op.request_body = request_body
+
+ response = GrapeSwagger::OpenAPI::Response.new
+ response.description = 'Webhook received successfully'
+ webhook_op.add_response(200, response)
+
+ webhook_path.add_operation(:post, webhook_op)
+ spec.add_webhook('newPet', webhook_path)
+
+ # Export as OAS 3.1
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:openapi]).to eq('3.1.0')
+ expect(output[:webhooks]).to have_key('newPet')
+ expect(output[:webhooks]['newPet'][:post][:summary]).to eq('New pet notification')
+ expect(output[:webhooks]['newPet'][:post][:requestBody]).to be_present
+ end
+ end
+end
+
+describe 'OpenAPI 3.1 jsonSchemaDialect' do
+ it 'exports jsonSchemaDialect when set' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Test API'
+ spec.info.version = '1.0'
+ spec.json_schema_dialect = 'https://json-schema.org/draft/2020-12/schema'
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:jsonSchemaDialect]).to eq('https://json-schema.org/draft/2020-12/schema')
+ end
+
+ it 'places jsonSchemaDialect after openapi version' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Test API'
+ spec.info.version = '1.0'
+ spec.json_schema_dialect = 'https://json-schema.org/draft/2020-12/schema'
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ keys = output.keys
+ openapi_index = keys.index(:openapi)
+ dialect_index = keys.index(:jsonSchemaDialect)
+ info_index = keys.index(:info)
+
+ expect(dialect_index).to be > openapi_index
+ expect(dialect_index).to be < info_index
+ end
+end
+
+describe 'OpenAPI 3.1 schema $schema keyword' do
+ it 'exports $schema keyword when set on schema' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Test API'
+ spec.info.version = '1.0'
+
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'object')
+ schema.json_schema = 'https://json-schema.org/draft/2020-12/schema'
+ schema.add_property('name', GrapeSwagger::OpenAPI::Schema.new(type: 'string'))
+
+ spec.components.add_schema('MyModel', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ expect(output[:components][:schemas]['MyModel'][:$schema]).to eq('https://json-schema.org/draft/2020-12/schema')
+ end
+end
+
+describe 'OpenAPI 3.1 contentMediaType and contentEncoding' do
+ it 'exports contentMediaType for binary content' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Test API'
+ spec.info.version = '1.0'
+
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string')
+ schema.content_media_type = 'image/png'
+ schema.content_encoding = 'base64'
+
+ spec.components.add_schema('ImageData', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS31.new(spec)
+ output = exporter.export
+
+ image_schema = output[:components][:schemas]['ImageData']
+ expect(image_schema[:contentMediaType]).to eq('image/png')
+ expect(image_schema[:contentEncoding]).to eq('base64')
+ end
+
+ it 'does not export contentMediaType in OAS 3.0' do
+ spec = GrapeSwagger::OpenAPI::Document.new
+ spec.info.title = 'Test API'
+ spec.info.version = '1.0'
+
+ schema = GrapeSwagger::OpenAPI::Schema.new(type: 'string')
+ schema.content_media_type = 'image/png'
+ schema.content_encoding = 'base64'
+
+ spec.components.add_schema('ImageData', schema)
+
+ exporter = GrapeSwagger::Exporter::OAS30.new(spec)
+ output = exporter.export
+
+ image_schema = output[:components][:schemas]['ImageData']
+ expect(image_schema).not_to have_key(:contentMediaType)
+ expect(image_schema).not_to have_key(:contentEncoding)
+ end
+end
diff --git a/spec/openapi_v3/openapi_version_spec.rb b/spec/openapi_v3/openapi_version_spec.rb
new file mode 100644
index 00000000..77b44543
--- /dev/null
+++ b/spec/openapi_v3/openapi_version_spec.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OpenAPI version configuration' do
+ describe 'default (Swagger 2.0)' do
+ before :all do
+ module TheApi
+ class Swagger20Api < Grape::API
+ format :json
+
+ desc 'Get something'
+ get '/something' do
+ { id: 1, name: 'Test' }
+ end
+
+ desc 'Create something'
+ params do
+ requires :name, type: String, desc: 'Name of the thing'
+ optional :description, type: String, desc: 'Description'
+ end
+ post '/something' do
+ { id: 1, name: params[:name] }
+ end
+
+ add_swagger_documentation
+ end
+ end
+ end
+
+ def app
+ TheApi::Swagger20Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns swagger 2.0 format' do
+ expect(subject['swagger']).to eq '2.0'
+ expect(subject).not_to have_key('openapi')
+ end
+
+ it 'does not have components' do
+ expect(subject).not_to have_key('components')
+ end
+ end
+
+ describe 'OpenAPI 3.0' do
+ before :all do
+ module TheApi
+ class OAS30Api < Grape::API
+ format :json
+
+ desc 'Get something'
+ get '/something' do
+ { id: 1, name: 'Test' }
+ end
+
+ desc 'Create something'
+ params do
+ requires :name, type: String, desc: 'Name of the thing'
+ optional :description, type: String, desc: 'Description'
+ end
+ post '/something' do
+ { id: 1, name: params[:name] }
+ end
+
+ desc 'Update something'
+ params do
+ requires :id, type: Integer, desc: 'ID of the thing'
+ requires :name, type: String, desc: 'Name of the thing'
+ end
+ put '/something/:id' do
+ { id: params[:id], name: params[:name] }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ TheApi::OAS30Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns openapi 3.0 format' do
+ expect(subject['openapi']).to eq '3.0.3'
+ expect(subject).not_to have_key('swagger')
+ end
+
+ it 'has components section' do
+ expect(subject).to have_key('components')
+ end
+
+ it 'uses requestBody instead of body parameters' do
+ post_op = subject['paths']['/something']['post']
+ expect(post_op).to have_key('requestBody')
+ body_params = (post_op['parameters'] || []).select { |p| p['in'] == 'body' }
+ expect(body_params).to be_empty
+ end
+
+ it 'wraps parameters in schema' do
+ put_op = subject['paths']['/something/{id}']['put']
+ path_params = put_op['parameters'].select { |p| p['in'] == 'path' }
+
+ path_params.each do |param|
+ expect(param).to have_key('schema')
+ expect(param['schema']).to have_key('type')
+ end
+ end
+
+ it 'uses content wrappers in requestBody' do
+ post_op = subject['paths']['/something']['post']
+ expect(post_op['requestBody']).to have_key('content')
+ end
+
+ it 'converts refs to components path' do
+ json_string = subject.to_json
+ expect(json_string).not_to include('#/definitions/')
+ end
+ end
+
+ describe 'OpenAPI 3.1' do
+ before :all do
+ module TheApi
+ class OAS31Api < Grape::API
+ format :json
+
+ desc 'Get something'
+ get '/something' do
+ { id: 1, name: 'Test' }
+ end
+
+ desc 'Create something'
+ params do
+ requires :name, type: String, desc: 'Name of the thing'
+ end
+ post '/something' do
+ { id: 1, name: params[:name] }
+ end
+
+ add_swagger_documentation(openapi_version: '3.1')
+ end
+ end
+ end
+
+ def app
+ TheApi::OAS31Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns openapi 3.1 format' do
+ expect(subject['openapi']).to eq '3.1.0'
+ expect(subject).not_to have_key('swagger')
+ end
+
+ it 'has components section' do
+ expect(subject).to have_key('components')
+ end
+ end
+end
diff --git a/spec/openapi_v3/param_type_body_nested_spec.rb b/spec/openapi_v3/param_type_body_nested_spec.rb
new file mode 100644
index 00000000..ad2625a5
--- /dev/null
+++ b/spec/openapi_v3/param_type_body_nested_spec.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'nested body parameters for OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class NestedBodyParamTypeApi < Grape::API
+ namespace :simple_nested_params do
+ desc 'post in body with nested parameters',
+ detail: 'more details description',
+ success: Entities::UseNestedWithAddress
+ params do
+ optional :contact, type: Hash, documentation: { additional_properties: true } do
+ requires :name, type: String, documentation: { desc: 'name', in: 'body' }
+ optional :addresses, type: Array do
+ requires :street, type: String, documentation: { desc: 'street', in: 'body' }
+ requires :postcode, type: String, documentation: { desc: 'postcode', in: 'body' }
+ requires :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ end
+ end
+
+ post '/in_body' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'put in body with nested parameters',
+ detail: 'more details description',
+ success: Entities::UseNestedWithAddress
+ params do
+ requires :id, type: Integer
+ optional :name, type: String, documentation: { desc: 'name', in: 'body' }
+ optional :address, type: Hash do
+ optional :street, type: String, documentation: { desc: 'street', in: 'body' }
+ optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' }
+ optional :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ end
+
+ put '/in_body/:id' do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ namespace :multiple_nested_params do
+ desc 'put in body with multiple nested parameters',
+ success: Entities::UseNestedWithAddress
+ params do
+ optional :contact, type: Hash do
+ requires :name, type: String, documentation: { desc: 'name', in: 'body' }
+ optional :addresses, type: Array do
+ optional :street, type: String, documentation: { desc: 'street', in: 'body' }
+ requires :postcode, type: Integer, documentation: { desc: 'postcode', in: 'formData' }
+ optional :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ optional :delivery_address, type: Hash do
+ optional :street, type: String, documentation: { desc: 'street', in: 'body' }
+ optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' }
+ optional :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ end
+ end
+
+ post '/in_body' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'put in body with multiple nested parameters',
+ success: Entities::UseNestedWithAddress
+ params do
+ requires :id, type: Integer
+ optional :name, type: String, documentation: { desc: 'name', in: 'body' }
+ optional :address, type: Hash do
+ optional :street, type: String, documentation: { desc: 'street', in: 'body' }
+ requires :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' }
+ optional :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ optional :delivery_address, type: Hash do
+ optional :street, type: String, documentation: { desc: 'street', in: 'body' }
+ optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' }
+ optional :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ end
+
+ put '/in_body/:id' do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ namespace :nested_params_array do
+ desc 'post in body with array of nested parameters',
+ detail: 'more details description',
+ success: Entities::UseNestedWithAddress
+ params do
+ optional :contacts, type: Array, documentation: { additional_properties: false } do
+ requires :name, type: String, documentation: { desc: 'name', in: 'body' }
+ optional :addresses, type: Array do
+ requires :street, type: String, documentation: { desc: 'street', in: 'body' }
+ requires :postcode, type: String, documentation: { desc: 'postcode', in: 'body' }
+ requires :city, type: String, documentation: { desc: 'city', in: 'body' }
+ optional :country, type: String, documentation: { desc: 'country', in: 'body' }
+ end
+ end
+ end
+
+ post '/in_body' do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::NestedBodyParamTypeApi
+ end
+
+ describe 'nested body parameters given' do
+ subject do
+ get '/swagger_doc/simple_nested_params'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'POST' do
+ let(:operation) { subject['paths']['/simple_nested_params/in_body']['post'] }
+
+ it 'has requestBody with schema reference' do
+ expect(operation['requestBody']).to include(
+ 'content' => {
+ 'application/json' => {
+ 'schema' => { '$ref' => '#/components/schemas/postSimpleNestedParamsInBody' }
+ }
+ }
+ )
+ end
+
+ it 'has no body parameters' do
+ params = operation['parameters'] || []
+ body_params = params.select { |p| p['in'] == 'body' }
+ expect(body_params).to be_empty
+ end
+
+ it 'defines nested schema in components' do
+ schema = subject['components']['schemas']['postSimpleNestedParamsInBody']
+ expect(schema['type']).to eq('object')
+ expect(schema['description']).to eq('post in body with nested parameters')
+
+ contact = schema['properties']['contact']
+ expect(contact['type']).to eq('object')
+ expect(contact['additionalProperties']).to eq(true)
+ expect(contact['required']).to eq(%w[name])
+
+ expect(contact['properties']['name']).to eq({
+ 'type' => 'string',
+ 'description' => 'name'
+ })
+
+ addresses = contact['properties']['addresses']
+ expect(addresses['type']).to eq('array')
+ expect(addresses['items']['type']).to eq('object')
+ expect(addresses['items']['required']).to eq(%w[street postcode city])
+ expect(addresses['items']['properties']['street']).to eq({
+ 'type' => 'string',
+ 'description' => 'street'
+ })
+ end
+ end
+
+ describe 'PUT' do
+ let(:operation) { subject['paths']['/simple_nested_params/in_body/{id}']['put'] }
+
+ it 'has path parameter with schema wrapper' do
+ path_param = operation['parameters'].find { |p| p['in'] == 'path' }
+ expect(path_param['name']).to eq('id')
+ expect(path_param['required']).to eq(true)
+ expect(path_param['schema']).to eq({ 'type' => 'integer', 'format' => 'int32' })
+ end
+
+ it 'has requestBody with schema reference' do
+ expect(operation['requestBody']['content']['application/json']['schema']).to eq({
+ '$ref' => '#/components/schemas/putSimpleNestedParamsInBodyId'
+ })
+ end
+
+ it 'defines nested schema with address object' do
+ schema = subject['components']['schemas']['putSimpleNestedParamsInBodyId']
+ expect(schema['type']).to eq('object')
+
+ expect(schema['properties']['name']).to eq({
+ 'type' => 'string',
+ 'description' => 'name'
+ })
+
+ address = schema['properties']['address']
+ expect(address['type']).to eq('object')
+ expect(address['properties']['street']).to eq({
+ 'type' => 'string',
+ 'description' => 'street'
+ })
+ expect(address['properties']['postcode']).to eq({
+ 'type' => 'string',
+ 'description' => 'postcode'
+ })
+ end
+ end
+ end
+
+ describe 'multiple nested body parameters given' do
+ subject do
+ get '/swagger_doc/multiple_nested_params'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'POST' do
+ let(:operation) { subject['paths']['/multiple_nested_params/in_body']['post'] }
+
+ it 'has requestBody reference' do
+ expect(operation['requestBody']['content']['application/json']['schema']).to eq({
+ '$ref' => '#/components/schemas/postMultipleNestedParamsInBody'
+ })
+ end
+
+ it 'defines schema with contact containing addresses and delivery_address' do
+ schema = subject['components']['schemas']['postMultipleNestedParamsInBody']
+ contact = schema['properties']['contact']
+
+ expect(contact['type']).to eq('object')
+ expect(contact['required']).to eq(%w[name])
+
+ # addresses array
+ addresses = contact['properties']['addresses']
+ expect(addresses['type']).to eq('array')
+ expect(addresses['items']['properties']['postcode']).to include(
+ 'type' => 'integer',
+ 'format' => 'int32'
+ )
+ expect(addresses['items']['required']).to eq(['postcode'])
+
+ # delivery_address object
+ delivery = contact['properties']['delivery_address']
+ expect(delivery['type']).to eq('object')
+ expect(delivery['properties']['street']).to eq({
+ 'type' => 'string',
+ 'description' => 'street'
+ })
+ end
+ end
+
+ describe 'PUT' do
+ let(:operation) { subject['paths']['/multiple_nested_params/in_body/{id}']['put'] }
+
+ it 'has path parameter and requestBody' do
+ path_param = operation['parameters'].find { |p| p['in'] == 'path' }
+ expect(path_param['name']).to eq('id')
+ expect(path_param['schema']['type']).to eq('integer')
+
+ expect(operation['requestBody']).to be_present
+ end
+
+ it 'defines schema with address and delivery_address' do
+ schema = subject['components']['schemas']['putMultipleNestedParamsInBodyId']
+
+ address = schema['properties']['address']
+ expect(address['type']).to eq('object')
+ expect(address['required']).to eq(['postcode'])
+
+ delivery = schema['properties']['delivery_address']
+ expect(delivery['type']).to eq('object')
+ expect(delivery['properties']['city']).to eq({
+ 'type' => 'string',
+ 'description' => 'city'
+ })
+ end
+ end
+ end
+
+ describe 'array of nested body parameters given' do
+ subject do
+ get '/swagger_doc/nested_params_array'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'POST' do
+ let(:operation) { subject['paths']['/nested_params_array/in_body']['post'] }
+
+ it 'has requestBody reference' do
+ expect(operation['requestBody']['content']['application/json']['schema']).to eq({
+ '$ref' => '#/components/schemas/postNestedParamsArrayInBody'
+ })
+ end
+
+ it 'defines schema with contacts array containing nested addresses' do
+ schema = subject['components']['schemas']['postNestedParamsArrayInBody']
+ expect(schema['type']).to eq('object')
+ expect(schema['description']).to eq('post in body with array of nested parameters')
+
+ contacts = schema['properties']['contacts']
+ expect(contacts['type']).to eq('array')
+
+ contact_item = contacts['items']
+ expect(contact_item['type']).to eq('object')
+ expect(contact_item['additionalProperties']).to eq(false)
+ expect(contact_item['required']).to eq(%w[name])
+
+ addresses = contact_item['properties']['addresses']
+ expect(addresses['type']).to eq('array')
+ expect(addresses['items']['type']).to eq('object')
+ expect(addresses['items']['required']).to eq(%w[street postcode city])
+ end
+ end
+ end
+end
diff --git a/spec/openapi_v3/param_type_body_spec.rb b/spec/openapi_v3/param_type_body_spec.rb
new file mode 100644
index 00000000..4b4d4f20
--- /dev/null
+++ b/spec/openapi_v3/param_type_body_spec.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OAS 3.0 requestBody from param_type: body' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module ParamTypeBodyTest
+ class BodyParamTypeApi < Grape::API
+ namespace :wo_entities do
+ desc 'post in body /wo entity'
+ params do
+ requires :in_body_1, type: Integer, documentation: { desc: 'in_body_1', param_type: 'body' }
+ optional :in_body_2, type: String, documentation: { desc: 'in_body_2', param_type: 'body' }
+ optional :in_body_3, type: String, documentation: { desc: 'in_body_3', param_type: 'body' }
+ end
+
+ post '/in_body' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'put in body /wo entity'
+ params do
+ requires :key, type: Integer
+ optional :in_body_1, type: Integer, documentation: { desc: 'in_body_1', param_type: 'body' }
+ optional :in_body_2, type: String, documentation: { desc: 'in_body_2', param_type: 'body' }
+ optional :in_body_3, type: String, documentation: { desc: 'in_body_3', param_type: 'body' }
+ end
+
+ put '/in_body/:key' do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ namespace :with_entities do
+ desc 'post in body with entity',
+ success: ::Entities::ResponseItem
+ params do
+ requires :name, type: String, documentation: { desc: 'name', param_type: 'body' }
+ end
+
+ post '/in_body' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'put in body with entity',
+ success: ::Entities::ResponseItem
+ params do
+ requires :id, type: Integer
+ optional :name, type: String, documentation: { desc: 'name', param_type: 'body' }
+ end
+
+ put '/in_body/:id' do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ namespace :with_entity_param do
+ desc 'post in body with entity parameter'
+ params do
+ optional :data, type: ::Entities::NestedModule::ApiResponse, documentation: { desc: 'request data' }
+ end
+
+ post do
+ { 'declared_params' => declared(params) }
+ end
+ end
+
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+ end
+
+ def app
+ ParamTypeBodyTest::BodyParamTypeApi
+ end
+
+ describe 'no entity given' do
+ subject do
+ get '/swagger_doc/wo_entities'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'POST /wo_entities/in_body' do
+ it 'has requestBody with schema reference' do
+ request_body = subject['paths']['/wo_entities/in_body']['post']['requestBody']
+ expect(request_body['content']['application/json']['schema']).to have_key('$ref')
+ end
+
+ it 'creates schema with all body parameters' do
+ # Find the schema for POST body
+ schemas = subject['components']['schemas']
+ post_schema = schemas.find { |name, _| name.include?('post') && name.include?('WoEntities') }&.last
+
+ expect(post_schema).to be_present
+ expect(post_schema['type']).to eq('object')
+ expect(post_schema['properties']).to have_key('in_body_1')
+ expect(post_schema['properties']).to have_key('in_body_2')
+ expect(post_schema['properties']).to have_key('in_body_3')
+ end
+
+ it 'marks required fields' do
+ schemas = subject['components']['schemas']
+ post_schema = schemas.find { |name, _| name.include?('post') && name.include?('WoEntities') }&.last
+
+ expect(post_schema['required']).to include('in_body_1')
+ end
+
+ it 'has correct types for body parameters' do
+ schemas = subject['components']['schemas']
+ post_schema = schemas.find { |name, _| name.include?('post') && name.include?('WoEntities') }&.last
+
+ expect(post_schema['properties']['in_body_1']['type']).to eq('integer')
+ expect(post_schema['properties']['in_body_2']['type']).to eq('string')
+ end
+ end
+
+ describe 'PUT /wo_entities/in_body/{key}' do
+ it 'has path parameter for :key' do
+ params = subject['paths']['/wo_entities/in_body/{key}']['put']['parameters']
+ key_param = params.find { |p| p['name'] == 'key' }
+
+ expect(key_param['in']).to eq('path')
+ expect(key_param['required']).to be true
+ expect(key_param['schema']['type']).to eq('integer')
+ end
+
+ it 'has requestBody with remaining body params' do
+ request_body = subject['paths']['/wo_entities/in_body/{key}']['put']['requestBody']
+ expect(request_body['content']['application/json']['schema']).to have_key('$ref')
+ end
+
+ it 'does not include path param in body schema' do
+ schemas = subject['components']['schemas']
+ put_schema = schemas.find { |name, _| name.include?('put') && name.include?('WoEntities') }&.last
+
+ expect(put_schema['properties']).not_to have_key('key')
+ expect(put_schema['properties']).to have_key('in_body_1')
+ end
+ end
+ end
+
+ describe 'entity given' do
+ subject do
+ get '/swagger_doc/with_entities'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'POST /with_entities/in_body' do
+ it 'has requestBody with schema reference' do
+ request_body = subject['paths']['/with_entities/in_body']['post']['requestBody']
+ expect(request_body['content']['application/json']['schema']).to have_key('$ref')
+ end
+
+ it 'creates schema with body parameters' do
+ schemas = subject['components']['schemas']
+ post_schema = schemas.find { |name, _| name.include?('post') && name.include?('WithEntities') }&.last
+
+ expect(post_schema['type']).to eq('object')
+ expect(post_schema['properties']['name']['type']).to eq('string')
+ expect(post_schema['required']).to include('name')
+ end
+ end
+
+ describe 'PUT /with_entities/in_body/{id}' do
+ it 'has path parameter' do
+ params = subject['paths']['/with_entities/in_body/{id}']['put']['parameters']
+ id_param = params.find { |p| p['name'] == 'id' }
+
+ expect(id_param['in']).to eq('path')
+ expect(id_param['required']).to be true
+ expect(id_param['schema']['type']).to eq('integer')
+ end
+
+ it 'has requestBody with body params' do
+ request_body = subject['paths']['/with_entities/in_body/{id}']['put']['requestBody']
+ expect(request_body['content']['application/json']['schema']).to have_key('$ref')
+ end
+ end
+ end
+
+ describe 'complex entity given' do
+ subject do
+ get '/swagger_doc/with_entity_param'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has requestBody with schema reference' do
+ request_body = subject['paths']['/with_entity_param']['post']['requestBody']
+ expect(request_body['content']['application/json']['schema']).to have_key('$ref')
+ end
+
+ it 'includes nested entity in components schemas' do
+ schemas = subject['components']['schemas']
+ nested_schema = schemas.find { |name, _| name.include?('NestedModule') || name.include?('ApiResponse') }
+ expect(nested_schema).to be_present
+ end
+
+ it 'has body schema with data property' do
+ schemas = subject['components']['schemas']
+ post_schema = schemas.find { |name, _| name.include?('post') && name.include?('WithEntityParam') }&.last
+
+ expect(post_schema['type']).to eq('object')
+ expect(post_schema['properties']).to have_key('data')
+ expect(post_schema['properties']['data']['description']).to eq('request data')
+ end
+ end
+end
diff --git a/spec/openapi_v3/param_type_spec.rb b/spec/openapi_v3/param_type_spec.rb
new file mode 100644
index 00000000..c99207c2
--- /dev/null
+++ b/spec/openapi_v3/param_type_spec.rb
@@ -0,0 +1,191 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'param types (query, path, header) in OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class ParamTypeApi < Grape::API
+ # using `:param_type`
+ desc 'full set of request param types',
+ success: Entities::UseResponse
+ params do
+ optional :in_query, type: String, documentation: { param_type: 'query' }
+ optional :in_header, type: String, documentation: { param_type: 'header' }
+ end
+
+ get '/defined_param_type' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'full set of request param types with path param',
+ success: Entities::UseResponse
+ params do
+ requires :in_path, type: Integer
+ optional :in_query, type: String, documentation: { param_type: 'query' }
+ optional :in_header, type: String, documentation: { param_type: 'header' }
+ end
+
+ get '/defined_param_type/:in_path' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'delete with param types',
+ success: Entities::UseResponse
+ params do
+ optional :in_path, type: Integer
+ optional :in_query, type: String, documentation: { param_type: 'query' }
+ optional :in_header, type: String, documentation: { param_type: 'header' }
+ end
+
+ delete '/defined_param_type/:in_path' do
+ { 'declared_params' => declared(params) }
+ end
+
+ # using `:in`
+ desc 'param types using `:in`',
+ success: Entities::UseResponse
+ params do
+ optional :in_query, type: String, documentation: { in: 'query' }
+ optional :in_header, type: String, documentation: { in: 'header' }
+ end
+
+ get '/defined_in' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'param types using `:in` with path',
+ success: Entities::UseResponse
+ params do
+ requires :in_path, type: Integer
+ optional :in_query, type: String, documentation: { in: 'query' }
+ optional :in_header, type: String, documentation: { in: 'header' }
+ end
+
+ get '/defined_in/:in_path' do
+ { 'declared_params' => declared(params) }
+ end
+
+ desc 'delete with param types using `:in`'
+ params do
+ optional :in_path, type: Integer
+ optional :in_query, type: String, documentation: { in: 'query' }
+ optional :in_header, type: String, documentation: { in: 'header' }
+ end
+
+ delete '/defined_in/:in_path' do
+ { 'declared_params' => declared(params) }
+ end
+
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::ParamTypeApi
+ end
+
+ describe 'OAS3 format' do
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns openapi 3.0.3' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'has response with content wrapper for success' do
+ response = subject['paths']['/defined_param_type/{in_path}']['delete']['responses']['200']
+ expect(response['content']['application/json']['schema']['$ref']).to eq('#/components/schemas/UseResponse')
+ end
+
+ it 'has 204 response without content for delete without success model' do
+ response = subject['paths']['/defined_in/{in_path}']['delete']['responses']['204']
+ expect(response['description']).to eq('delete with param types using `:in`')
+ expect(response['content']).to be_nil
+ end
+ end
+
+ describe 'defined param types with :param_type' do
+ subject do
+ get '/swagger_doc/defined_param_type'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has query and header params with schema wrapper' do
+ params = subject['paths']['/defined_param_type']['get']['parameters']
+
+ query_param = params.find { |p| p['name'] == 'in_query' }
+ expect(query_param['in']).to eq('query')
+ expect(query_param['required']).to eq(false)
+ expect(query_param['schema']).to eq({ 'type' => 'string' })
+
+ header_param = params.find { |p| p['name'] == 'in_header' }
+ expect(header_param['in']).to eq('header')
+ expect(header_param['required']).to eq(false)
+ expect(header_param['schema']).to eq({ 'type' => 'string' })
+ end
+
+ it 'has path param with schema wrapper' do
+ params = subject['paths']['/defined_param_type/{in_path}']['get']['parameters']
+
+ path_param = params.find { |p| p['name'] == 'in_path' }
+ expect(path_param['in']).to eq('path')
+ expect(path_param['required']).to eq(true)
+ expect(path_param['schema']).to eq({ 'type' => 'integer', 'format' => 'int32' })
+ end
+
+ it 'has all three param types for path with params' do
+ params = subject['paths']['/defined_param_type/{in_path}']['get']['parameters']
+
+ expect(params.length).to eq(3)
+ expect(params.map { |p| p['in'] }).to contain_exactly('path', 'query', 'header')
+ end
+
+ it 'has params for delete operation' do
+ params = subject['paths']['/defined_param_type/{in_path}']['delete']['parameters']
+
+ expect(params.length).to eq(3)
+ path_param = params.find { |p| p['in'] == 'path' }
+ expect(path_param['schema']['type']).to eq('integer')
+ end
+ end
+
+ describe 'defined param types with :in' do
+ subject do
+ get '/swagger_doc/defined_in'
+ JSON.parse(last_response.body)
+ end
+
+ it 'has query and header params with schema wrapper' do
+ params = subject['paths']['/defined_in']['get']['parameters']
+
+ query_param = params.find { |p| p['name'] == 'in_query' }
+ expect(query_param['in']).to eq('query')
+ expect(query_param['schema']).to eq({ 'type' => 'string' })
+
+ header_param = params.find { |p| p['name'] == 'in_header' }
+ expect(header_param['in']).to eq('header')
+ expect(header_param['schema']).to eq({ 'type' => 'string' })
+ end
+
+ it 'has path param with integer schema' do
+ params = subject['paths']['/defined_in/{in_path}']['get']['parameters']
+
+ path_param = params.find { |p| p['name'] == 'in_path' }
+ expect(path_param['in']).to eq('path')
+ expect(path_param['required']).to eq(true)
+ expect(path_param['schema']['type']).to eq('integer')
+ end
+
+ it 'has all params for delete' do
+ params = subject['paths']['/defined_in/{in_path}']['delete']['parameters']
+
+ expect(params.length).to eq(3)
+ end
+ end
+end
diff --git a/spec/openapi_v3/params_array_spec.rb b/spec/openapi_v3/params_array_spec.rb
new file mode 100644
index 00000000..de59e274
--- /dev/null
+++ b/spec/openapi_v3/params_array_spec.rb
@@ -0,0 +1,206 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'OAS 3.0 Group Params as Array' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ [true, false].each do |array_use_braces|
+ context "when array_use_braces option is set to #{array_use_braces}" do
+ let(:braces) { array_use_braces ? '[]' : '' }
+
+ let(:app) do
+ braces_setting = array_use_braces
+ Class.new(Grape::API) do
+ format :json
+
+ params do
+ requires :required_group, type: Array do
+ requires :required_param_1
+ requires :required_param_2
+ end
+ end
+ post '/groups' do
+ { 'declared_params' => declared(params) }
+ end
+
+ params do
+ requires :typed_group, type: Array do
+ requires :id, type: Integer, desc: 'integer given'
+ requires :name, type: String, desc: 'string given'
+ optional :email, type: String, desc: 'email given'
+ optional :others, type: Integer, values: [1, 2, 3]
+ end
+ end
+ post '/type_given' do
+ { 'declared_params' => declared(params) }
+ end
+
+ params do
+ requires :array_of_string, type: Array[String], documentation: { param_type: 'body', desc: 'nested array of strings' }
+ requires :array_of_integer, type: Array[Integer], documentation: { param_type: 'body', desc: 'nested array of integers' }
+ end
+ post '/array_of_type' do
+ { 'declared_params' => declared(params) }
+ end
+
+ params do
+ requires :array_of_string, type: Array[String], documentation: { param_type: 'body', desc: 'array of strings' }
+ requires :integer_value, type: Integer, documentation: { param_type: 'body', desc: 'integer value' }
+ end
+ post '/object_and_array' do
+ { 'declared_params' => declared(params) }
+ end
+
+ params do
+ requires :array_of_string, type: Array[String]
+ requires :array_of_integer, type: Array[Integer]
+ end
+ post '/array_of_type_in_form' do
+ { 'declared_params' => declared(params) }
+ end
+
+ params do
+ requires :array_of_entities, type: Array[Entities::ApiError]
+ end
+ post '/array_of_entities' do
+ { 'declared_params' => declared(params) }
+ end
+
+ add_swagger_documentation openapi_version: '3.0', array_use_braces: braces_setting
+ end
+ end
+
+ describe 'grouped parameters' do
+ subject do
+ get '/swagger_doc/groups'
+ JSON.parse(last_response.body)
+ end
+
+ it 'creates requestBody for grouped array parameters' do
+ request_body = subject['paths']['/groups']['post']['requestBody']
+ expect(request_body).to be_present
+ expect(request_body['content']).to be_present
+ end
+
+ it 'has array type properties for group members in component schema' do
+ # Our implementation uses $ref to reference schemas
+ schemas = subject['components']['schemas']
+ schema = schemas['postGroups']
+
+ expect(schema['type']).to eq('object')
+ expect(schema['properties']).to be_present
+ expect(schema['properties']['required_group']['type']).to eq('array')
+ end
+ end
+
+ describe 'typed group parameters' do
+ subject do
+ get '/swagger_doc/type_given'
+ JSON.parse(last_response.body)
+ end
+
+ it 'creates requestBody with schema' do
+ request_body = subject['paths']['/type_given']['post']['requestBody']
+ expect(request_body).to be_present
+ end
+
+ it 'has typed_group as array in component schema' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postTypeGiven']
+
+ expect(schema['type']).to eq('object')
+ expect(schema['properties']['typed_group']['type']).to eq('array')
+ end
+ end
+
+ describe 'array of primitive types in body' do
+ subject do
+ get '/swagger_doc/array_of_type'
+ JSON.parse(last_response.body)
+ end
+
+ it 'creates object schema in components with array properties' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postArrayOfType']
+
+ expect(schema).to be_present
+ expect(schema['type']).to eq('object')
+ expect(schema['properties']['array_of_string']['type']).to eq('array')
+ expect(schema['properties']['array_of_integer']['type']).to eq('array')
+ end
+
+ it 'has items with correct primitive types' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postArrayOfType']
+
+ expect(schema['properties']['array_of_string']['items']['type']).to eq('string')
+ expect(schema['properties']['array_of_integer']['items']['type']).to eq('integer')
+ end
+
+ it 'includes descriptions for array properties' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postArrayOfType']
+
+ expect(schema['properties']['array_of_string']['description']).to eq('nested array of strings')
+ expect(schema['properties']['array_of_integer']['description']).to eq('nested array of integers')
+ end
+ end
+
+ describe 'mixed object and array parameters' do
+ subject do
+ get '/swagger_doc/object_and_array'
+ JSON.parse(last_response.body)
+ end
+
+ it 'creates object schema with array property' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postObjectAndArray']
+
+ expect(schema['type']).to eq('object')
+ expect(schema['properties']['array_of_string']['type']).to eq('array')
+ expect(schema['properties']['integer_value']['type']).to eq('integer')
+ end
+ end
+
+ describe 'array of type in form' do
+ subject do
+ get '/swagger_doc/array_of_type_in_form'
+ JSON.parse(last_response.body)
+ end
+
+ it 'creates requestBody with content' do
+ request_body = subject['paths']['/array_of_type_in_form']['post']['requestBody']
+ expect(request_body['content']).to be_present
+ end
+
+ it 'has array properties in component schema' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postArrayOfTypeInForm']
+
+ expect(schema['properties']).to be_present
+ expect(schema['properties']['array_of_string']['type']).to eq('array')
+ expect(schema['properties']['array_of_integer']['type']).to eq('array')
+ end
+ end
+
+ describe 'array of entities' do
+ subject do
+ get '/swagger_doc/array_of_entities'
+ JSON.parse(last_response.body)
+ end
+
+ it 'includes entity schema in components' do
+ expect(subject['components']['schemas']['ApiError']).to be_present
+ end
+
+ it 'has array property in component schema' do
+ schemas = subject['components']['schemas']
+ schema = schemas['postArrayOfEntities']
+
+ expect(schema['properties']['array_of_entities']['type']).to eq('array')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/openapi_v3/response_headers_spec.rb b/spec/openapi_v3/response_headers_spec.rb
new file mode 100644
index 00000000..6b18b447
--- /dev/null
+++ b/spec/openapi_v3/response_headers_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Response headers in OpenAPI 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApi
+ class ResponseHeadersOAS3Api < Grape::API
+ format :json
+
+ desc 'Endpoint with response headers' do
+ success model: Entities::UseResponse, headers: {
+ 'X-Request-Id' => { description: 'Request identifier', type: 'string' },
+ 'X-Rate-Limit' => { description: 'Rate limit remaining', type: 'integer' }
+ }
+ failure [
+ [404, 'Not Found', Entities::ApiError, nil, { 'X-Error-Code' => { description: 'Error code', type: 'string' } }]
+ ]
+ end
+ get '/with_headers' do
+ { data: 'response' }
+ end
+
+ add_swagger_documentation(openapi_version: '3.0')
+ end
+ end
+ end
+
+ def app
+ TheApi::ResponseHeadersOAS3Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'success response headers' do
+ let(:success_response) { subject['paths']['/with_headers']['get']['responses']['200'] }
+
+ it 'includes headers in response' do
+ expect(success_response).to have_key('headers')
+ end
+
+ it 'wraps header type in schema for OAS3' do
+ request_id_header = success_response['headers']['X-Request-Id']
+ expect(request_id_header).to have_key('schema')
+ expect(request_id_header['schema']['type']).to eq('string')
+ end
+
+ it 'includes header description' do
+ request_id_header = success_response['headers']['X-Request-Id']
+ expect(request_id_header['description']).to eq('Request identifier')
+ end
+
+ it 'handles integer type headers' do
+ rate_limit_header = success_response['headers']['X-Rate-Limit']
+ expect(rate_limit_header['schema']['type']).to eq('integer')
+ end
+ end
+
+ describe 'error response headers' do
+ let(:error_response) { subject['paths']['/with_headers']['get']['responses']['404'] }
+
+ it 'includes headers in error response' do
+ expect(error_response).to have_key('headers')
+ end
+
+ it 'wraps error header type in schema' do
+ error_header = error_response['headers']['X-Error-Code']
+ expect(error_header).to have_key('schema')
+ expect(error_header['schema']['type']).to eq('string')
+ end
+ end
+end
diff --git a/spec/openapi_v3/response_with_models_spec.rb b/spec/openapi_v3/response_with_models_spec.rb
new file mode 100644
index 00000000..12cdeb17
--- /dev/null
+++ b/spec/openapi_v3/response_with_models_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'response with models for OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class ResponseApiModels < Grape::API
+ format :json
+
+ desc 'This returns something',
+ success: [{ code: 200 }],
+ failure: [
+ { code: 400, message: 'NotFound', model: '' },
+ { code: 404, message: 'BadRequest', model: Entities::ApiError }
+ ],
+ default_response: { message: 'Error', model: Entities::ApiError }
+ get '/use-response' do
+ { 'declared_params' => declared(params) }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.0',
+ models: [Entities::UseResponse]
+ )
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::ResponseApiModels
+ end
+
+ describe 'uses entity as response object implicitly with route name' do
+ subject do
+ get '/swagger_doc/use-response'
+ JSON.parse(last_response.body)
+ end
+
+ it 'returns openapi version' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+
+ it 'has operation with responses using components/schemas references' do
+ operation = subject['paths']['/use-response']['get']
+
+ expect(operation['description']).to eq('This returns something')
+ expect(operation['tags']).to eq(['use-response'])
+ expect(operation['operationId']).to eq('getUseResponse')
+ end
+
+ it 'has success response with schema reference in content' do
+ response = subject['paths']['/use-response']['get']['responses']['200']
+
+ expect(response['description']).to eq('This returns something')
+ expect(response['content']).to eq({
+ 'application/json' => {
+ 'schema' => { '$ref' => '#/components/schemas/UseResponse' }
+ }
+ })
+ end
+
+ it 'has failure response without model (empty schema)' do
+ response = subject['paths']['/use-response']['get']['responses']['400']
+
+ expect(response['description']).to eq('NotFound')
+ expect(response['content']).to be_nil
+ end
+
+ it 'has failure response with model' do
+ response = subject['paths']['/use-response']['get']['responses']['404']
+
+ expect(response['description']).to eq('BadRequest')
+ expect(response['content']).to eq({
+ 'application/json' => {
+ 'schema' => { '$ref' => '#/components/schemas/ApiError' }
+ }
+ })
+ end
+
+ it 'has default response with model' do
+ response = subject['paths']['/use-response']['get']['responses']['default']
+
+ expect(response['description']).to eq('Error')
+ expect(response['content']).to eq({
+ 'application/json' => {
+ 'schema' => { '$ref' => '#/components/schemas/ApiError' }
+ }
+ })
+ end
+
+ it 'defines schemas in components/schemas instead of definitions' do
+ expect(subject['definitions']).to be_nil
+ expect(subject['components']['schemas']).to include('UseResponse', 'ApiError')
+ end
+
+ it 'does not have produces at operation level' do
+ operation = subject['paths']['/use-response']['get']
+ expect(operation['produces']).to be_nil
+ end
+ end
+end
diff --git a/spec/openapi_v3/security_schemes_spec.rb b/spec/openapi_v3/security_schemes_spec.rb
new file mode 100644
index 00000000..b6cfec8a
--- /dev/null
+++ b/spec/openapi_v3/security_schemes_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Security schemes in OpenAPI 3.0' do
+ before :all do
+ module TheApi
+ class SecurityOAS3Api < Grape::API
+ format :json
+
+ desc 'Protected endpoint', security: [{ api_key: [] }]
+ get '/protected' do
+ { secret: 'data' }
+ end
+
+ desc 'OAuth protected', security: [{ oauth2: ['read:users', 'write:users'] }]
+ get '/oauth_protected' do
+ { user: 'data' }
+ end
+
+ desc 'Public endpoint', security: []
+ get '/public' do
+ { public: 'data' }
+ end
+
+ add_swagger_documentation(
+ openapi_version: '3.0',
+ security_definitions: {
+ api_key: {
+ type: 'apiKey',
+ name: 'X-API-Key',
+ in: 'header',
+ description: 'API Key authentication'
+ },
+ basic_auth: {
+ type: 'basic',
+ description: 'Basic HTTP authentication'
+ },
+ oauth2: {
+ type: 'oauth2',
+ flow: 'accessCode',
+ authorizationUrl: 'https://example.com/oauth/authorize',
+ tokenUrl: 'https://example.com/oauth/token',
+ scopes: {
+ 'read:users': 'Read user data',
+ 'write:users': 'Modify user data'
+ }
+ }
+ },
+ security: [{ api_key: [] }]
+ )
+ end
+ end
+ end
+
+ def app
+ TheApi::SecurityOAS3Api
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'security schemes location' do
+ it 'places security schemes under components' do
+ expect(subject['components']).to have_key('securitySchemes')
+ end
+
+ it 'does not have securityDefinitions at root level' do
+ expect(subject).not_to have_key('securityDefinitions')
+ end
+ end
+
+ describe 'apiKey security scheme' do
+ let(:api_key_scheme) { subject['components']['securitySchemes']['api_key'] }
+
+ it 'preserves apiKey type' do
+ expect(api_key_scheme['type']).to eq('apiKey')
+ end
+
+ it 'includes name and location' do
+ expect(api_key_scheme['name']).to eq('X-API-Key')
+ expect(api_key_scheme['in']).to eq('header')
+ end
+
+ it 'includes description' do
+ expect(api_key_scheme['description']).to eq('API Key authentication')
+ end
+ end
+
+ describe 'basic auth conversion' do
+ let(:basic_scheme) { subject['components']['securitySchemes']['basic_auth'] }
+
+ it 'converts basic to http type' do
+ expect(basic_scheme['type']).to eq('http')
+ end
+
+ it 'sets scheme to basic' do
+ expect(basic_scheme['scheme']).to eq('basic')
+ end
+ end
+
+ describe 'OAuth2 security scheme' do
+ let(:oauth_scheme) { subject['components']['securitySchemes']['oauth2'] }
+
+ it 'preserves oauth2 type' do
+ expect(oauth_scheme['type']).to eq('oauth2')
+ end
+
+ it 'converts flow to OAS3 flows format' do
+ expect(oauth_scheme).to have_key('flows')
+ end
+
+ it 'maps accessCode flow to authorizationCode' do
+ expect(oauth_scheme['flows']).to have_key('authorizationCode')
+ end
+
+ it 'includes authorization and token URLs' do
+ flow = oauth_scheme['flows']['authorizationCode']
+ expect(flow['authorizationUrl']).to eq('https://example.com/oauth/authorize')
+ expect(flow['tokenUrl']).to eq('https://example.com/oauth/token')
+ end
+
+ it 'includes scopes' do
+ flow = oauth_scheme['flows']['authorizationCode']
+ expect(flow['scopes']).to include('read:users' => 'Read user data')
+ end
+ end
+
+ describe 'operation-level security' do
+ it 'applies security to protected endpoint' do
+ security = subject['paths']['/protected']['get']['security']
+ expect(security).to eq([{ 'api_key' => [] }])
+ end
+
+ it 'applies OAuth security with scopes' do
+ security = subject['paths']['/oauth_protected']['get']['security']
+ expect(security).to eq([{ 'oauth2' => ['read:users', 'write:users'] }])
+ end
+
+ it 'allows empty security for public endpoints' do
+ security = subject['paths']['/public']['get']['security']
+ expect(security).to eq([])
+ end
+ end
+
+ describe 'global security' do
+ it 'applies global security requirement' do
+ expect(subject['security']).to eq([{ 'api_key' => [] }])
+ end
+ end
+end
diff --git a/spec/openapi_v3/status_codes_spec.rb b/spec/openapi_v3/status_codes_spec.rb
new file mode 100644
index 00000000..706cbc6e
--- /dev/null
+++ b/spec/openapi_v3/status_codes_spec.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'HTTP status code handling in OAS 3.0' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'OAS3 format verification' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Simple endpoint'
+ get '/test' do
+ {}
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'returns openapi 3.0.3' do
+ expect(subject['openapi']).to eq('3.0.3')
+ end
+ end
+
+ context 'when non-default success codes are defined' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Has explicit success http_codes defined' do
+ http_codes [{ code: 202, message: 'We got it!' },
+ { code: 204, message: 'Or returned no content' },
+ { code: 400, message: 'Bad request' }]
+ end
+
+ post '/accepting_endpoint' do
+ 'We got the message!'
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'only includes the defined http_codes' do
+ response_codes = subject['paths']['/accepting_endpoint']['post']['responses'].keys
+ expect(response_codes.sort).to eq(%w[202 204 400].sort)
+ end
+
+ it 'responses have proper OAS3 structure' do
+ responses = subject['paths']['/accepting_endpoint']['post']['responses']
+ expect(responses['202']['description']).to eq('We got it!')
+ expect(responses['204']['description']).to eq('Or returned no content')
+ expect(responses['400']['description']).to eq('Bad request')
+ end
+ end
+
+ context 'when success and failures are defined' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Has explicit success http_codes defined' do
+ success code: 202, model: Entities::UseResponse, message: 'a changed status code'
+ failure [[400, 'Bad Request']]
+ end
+
+ post '/accepting_endpoint' do
+ 'We got the message!'
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'only includes the defined http codes' do
+ response_codes = subject['paths']['/accepting_endpoint']['post']['responses'].keys
+ expect(response_codes.sort).to eq(%w[202 400].sort)
+ end
+
+ it 'success response has content with schema ref' do
+ response = subject['paths']['/accepting_endpoint']['post']['responses']['202']
+ expect(response['content']['application/json']['schema']['$ref']).to eq(
+ '#/components/schemas/UseResponse'
+ )
+ end
+
+ it 'failure response has description' do
+ response = subject['paths']['/accepting_endpoint']['post']['responses']['400']
+ expect(response['description']).to eq('Bad Request')
+ end
+ end
+
+ context 'when no success codes defined' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Has explicit error http_codes defined' do
+ http_codes [{ code: 400, message: 'Error!' },
+ { code: 404, message: 'Not found' }]
+ end
+
+ post '/error_endpoint' do
+ 'We got the message!'
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'adds default success code to the response' do
+ response_codes = subject['paths']['/error_endpoint']['post']['responses'].keys
+ expect(response_codes.sort).to eq(%w[201 400 404].sort)
+ end
+ end
+
+ context 'when success and error codes are defined' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Has success and error codes defined' do
+ http_codes [{ code: 200, message: 'Found' },
+ { code: 404, message: 'Not found' }]
+ end
+
+ get '/endpoint' do
+ 'We got the message!'
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'includes both success and error codes' do
+ response_codes = subject['paths']['/endpoint']['get']['responses'].keys
+ expect(response_codes.sort).to eq(%w[200 404].sort)
+ end
+
+ it 'responses have descriptions' do
+ responses = subject['paths']['/endpoint']['get']['responses']
+ expect(responses['200']['description']).to eq('Found')
+ expect(responses['404']['description']).to eq('Not found')
+ end
+ end
+
+ context 'DELETE endpoint default status code' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Delete something'
+ delete '/resource/:id' do
+ {}
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'uses 204 as default for DELETE' do
+ response_codes = subject['paths']['/resource/{id}']['delete']['responses'].keys
+ expect(response_codes).to include('204')
+ end
+ end
+
+ context 'POST endpoint default status code' do
+ let(:app) do
+ Class.new(Grape::API) do
+ desc 'Create something'
+ post '/resource' do
+ {}
+ end
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+
+ it 'uses 201 as default for POST' do
+ response_codes = subject['paths']['/resource']['post']['responses'].keys
+ expect(response_codes).to include('201')
+ end
+ end
+end
diff --git a/spec/openapi_v3/type_format_spec.rb b/spec/openapi_v3/type_format_spec.rb
new file mode 100644
index 00000000..fb6b2922
--- /dev/null
+++ b/spec/openapi_v3/type_format_spec.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# mapping of parameter types
+# Grape -> OpenAPI 3.0
+# (type format)
+# ---------------------------------------------------
+# Integer -> integer int32
+# Numeric -> integer int64
+# Float -> number float
+# BigDecimal -> number double
+# String -> string
+# Symbol -> string
+# Date -> string date
+# DateTime -> string date-time
+# Time -> string date-time
+# 'password' -> string password
+# 'email' -> string email
+# Boolean -> boolean
+# JSON -> object
+# Rack::Multipart::UploadedFile -> string binary
+# File -> string binary
+
+describe 'OAS 3.0 type format settings' do
+ include_context "#{MODEL_PARSER} swagger example"
+
+ before :all do
+ module TheApiOAS3
+ class TypeFormatApi < Grape::API
+ desc 'full set of request data types',
+ success: Entities::TypedDefinition
+
+ params do
+ # grape supported data types
+ requires :param_integer, type: Integer
+ requires :param_long, type: Numeric
+ requires :param_float, type: Float
+ requires :param_double, type: BigDecimal
+ optional :param_string, type: String
+ optional :param_symbol, type: Symbol
+ requires :param_date, type: Date
+ requires :param_date_time, type: DateTime
+ requires :param_time, type: Time
+ optional :param_boolean, type: Boolean
+ optional :param_file, type: File
+ optional :param_json, type: JSON
+ end
+
+ post '/request_types' do
+ { 'declared_params' => declared(params) }
+ end
+
+ add_swagger_documentation openapi_version: '3.0'
+ end
+ end
+ end
+
+ def app
+ TheApiOAS3::TypeFormatApi
+ end
+
+ subject do
+ get '/swagger_doc/request_types'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'requestBody schema' do
+ let(:request_body) { subject['paths']['/request_types']['post']['requestBody'] }
+ let(:schemas) { subject['components']['schemas'] }
+ let(:body_schema) { schemas['postRequestTypes'] }
+
+ it 'has requestBody with content' do
+ expect(request_body).to be_present
+ expect(request_body['content']).to be_present
+ end
+
+ it 'references component schema' do
+ expect(request_body['content']['application/json']['schema']['$ref']).to eq('#/components/schemas/postRequestTypes')
+ end
+
+ describe 'integer types' do
+ it 'maps Integer to integer/int32' do
+ prop = body_schema['properties']['param_integer']
+ expect(prop['type']).to eq('integer')
+ expect(prop['format']).to eq('int32')
+ end
+
+ it 'maps Numeric to integer/int64' do
+ prop = body_schema['properties']['param_long']
+ expect(prop['type']).to eq('integer')
+ expect(prop['format']).to eq('int64')
+ end
+ end
+
+ describe 'number types' do
+ it 'maps Float to number/float' do
+ prop = body_schema['properties']['param_float']
+ expect(prop['type']).to eq('number')
+ expect(prop['format']).to eq('float')
+ end
+
+ it 'maps BigDecimal to number/double' do
+ prop = body_schema['properties']['param_double']
+ expect(prop['type']).to eq('number')
+ expect(prop['format']).to eq('double')
+ end
+ end
+
+ describe 'string types' do
+ it 'maps String to string' do
+ prop = body_schema['properties']['param_string']
+ expect(prop['type']).to eq('string')
+ end
+
+ it 'maps Symbol to string' do
+ prop = body_schema['properties']['param_symbol']
+ expect(prop['type']).to eq('string')
+ end
+ end
+
+ describe 'date/time types' do
+ it 'maps Date to string/date' do
+ prop = body_schema['properties']['param_date']
+ expect(prop['type']).to eq('string')
+ expect(prop['format']).to eq('date')
+ end
+
+ it 'maps DateTime to string/date-time' do
+ prop = body_schema['properties']['param_date_time']
+ expect(prop['type']).to eq('string')
+ expect(prop['format']).to eq('date-time')
+ end
+
+ it 'maps Time to string/date-time' do
+ prop = body_schema['properties']['param_time']
+ expect(prop['type']).to eq('string')
+ expect(prop['format']).to eq('date-time')
+ end
+ end
+
+ describe 'boolean type' do
+ it 'maps Boolean to boolean' do
+ prop = body_schema['properties']['param_boolean']
+ expect(prop['type']).to eq('boolean')
+ end
+ end
+
+ describe 'file type' do
+ it 'maps File to string/binary' do
+ prop = body_schema['properties']['param_file']
+ expect(prop['type']).to eq('string')
+ expect(prop['format']).to eq('binary')
+ end
+ end
+
+ describe 'json type' do
+ it 'maps JSON to object' do
+ prop = body_schema['properties']['param_json']
+ expect(prop['type']).to eq('object')
+ end
+ end
+
+ describe 'required fields' do
+ it 'marks required parameters' do
+ required = body_schema['required']
+ expect(required).to include('param_integer')
+ expect(required).to include('param_long')
+ expect(required).to include('param_float')
+ expect(required).to include('param_double')
+ expect(required).to include('param_date')
+ expect(required).to include('param_date_time')
+ expect(required).to include('param_time')
+ end
+
+ it 'does not include optional parameters in required' do
+ required = body_schema['required']
+ expect(required).not_to include('param_string')
+ expect(required).not_to include('param_symbol')
+ expect(required).not_to include('param_boolean')
+ expect(required).not_to include('param_file')
+ expect(required).not_to include('param_json')
+ end
+ end
+ end
+
+ describe 'response schema' do
+ it 'includes TypedDefinition in components schemas' do
+ expect(subject['components']['schemas']['TypedDefinition']).to be_present
+ end
+
+ # Only run detailed property tests with entity parser since mock parser returns different structure
+ context 'with entity parser', if: MODEL_PARSER == 'entity' do
+ # OAS 3.0 uses different type representations than Swagger 2.0
+ # - 'file' becomes 'string' with format 'binary'
+ # - 'json' becomes 'object'
+ let(:oas3_typed_definition) do
+ {
+ 'prop_boolean' => { 'description' => 'prop_boolean description', 'type' => 'boolean' },
+ 'prop_date' => { 'description' => 'prop_date description', 'type' => 'string', 'format' => 'date' },
+ 'prop_date_time' => { 'description' => 'prop_date_time description', 'type' => 'string', 'format' => 'date-time' },
+ 'prop_double' => { 'description' => 'prop_double description', 'type' => 'number', 'format' => 'double' },
+ 'prop_email' => { 'description' => 'prop_email description', 'type' => 'string', 'format' => 'email' },
+ 'prop_file' => { 'description' => 'prop_file description', 'type' => 'string', 'format' => 'binary' },
+ 'prop_float' => { 'description' => 'prop_float description', 'type' => 'number', 'format' => 'float' },
+ 'prop_integer' => { 'description' => 'prop_integer description', 'type' => 'integer', 'format' => 'int32' },
+ 'prop_json' => { 'description' => 'prop_json description', 'type' => 'object' },
+ 'prop_long' => { 'description' => 'prop_long description', 'type' => 'integer', 'format' => 'int64' },
+ 'prop_password' => { 'description' => 'prop_password description', 'type' => 'string', 'format' => 'password' },
+ 'prop_string' => { 'description' => 'prop_string description', 'type' => 'string' },
+ 'prop_symbol' => { 'description' => 'prop_symbol description', 'type' => 'string' },
+ 'prop_time' => { 'description' => 'prop_time description', 'type' => 'string', 'format' => 'date-time' }
+ }
+ end
+
+ it 'has expected properties for TypedDefinition' do
+ typed_def = subject['components']['schemas']['TypedDefinition']
+ expect(typed_def['properties']).to eq(oas3_typed_definition)
+ end
+ end
+ end
+end
diff --git a/spec/support/route_helper.rb b/spec/support/route_helper.rb
index 6e87ccb5..e32a614a 100644
--- a/spec/support/route_helper.rb
+++ b/spec/support/route_helper.rb
@@ -2,7 +2,19 @@
module RouteHelper
def self.build(method:, pattern:, options:, origin: nil)
- if GrapeVersion.satisfy?('>= 2.3.0')
+ if GrapeVersion.satisfy?('>= 3.1.0')
+ # Grape 3.1+ has new Route constructor: (endpoint, method, pattern_object, options)
+ pattern_obj = Grape::Router::Pattern.new(
+ origin: origin || pattern,
+ suffix: '',
+ anchor: options.fetch(:anchor, true),
+ params: options[:params] || {},
+ format: nil,
+ version: nil,
+ requirements: options[:requirements] || {}
+ )
+ Grape::Router::Route.new(nil, method, pattern_obj, options)
+ elsif GrapeVersion.satisfy?('>= 2.3.0')
Grape::Router::Route.new(method, origin || pattern, pattern, options)
else
Grape::Router::Route.new(method, pattern, **options)
diff --git a/spec/swagger_v2/nullable_spec.rb b/spec/swagger_v2/nullable_spec.rb
new file mode 100644
index 00000000..0deda742
--- /dev/null
+++ b/spec/swagger_v2/nullable_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'nullable extension in Swagger 2.0' do
+ let(:app) do
+ Class.new(Grape::API) do
+ namespace :items do
+ params do
+ optional :nickname, type: String, documentation: { nullable: true }
+ optional :regular_field, type: String
+ end
+ post do
+ { message: 'created' }
+ end
+
+ params do
+ optional :search, type: String, documentation: { nullable: true }
+ end
+ get do
+ []
+ end
+ end
+
+ add_swagger_documentation
+ end
+ end
+
+ subject do
+ get '/swagger_doc'
+ JSON.parse(last_response.body)
+ end
+
+ describe 'body parameters (POST)' do
+ it 'uses x-nullable extension in definition schema' do
+ definition = subject['definitions']['postItems']
+ props = definition['properties']
+
+ expect(props['nickname']['x-nullable']).to eq(true)
+ expect(props['nickname']).not_to have_key('nullable')
+ expect(props['regular_field']).not_to have_key('x-nullable')
+ expect(props['regular_field']).not_to have_key('nullable')
+ end
+ end
+
+ describe 'query parameters (GET)' do
+ it 'uses x-nullable extension for query params' do
+ params = subject['paths']['/items']['get']['parameters']
+ nullable_param = params.find { |p| p['name'] == 'search' }
+
+ expect(nullable_param['x-nullable']).to eq(true)
+ expect(nullable_param).not_to have_key('nullable')
+ end
+ end
+end
diff --git a/spec/swagger_v2/reference_entity_spec.rb b/spec/swagger_v2/reference_entity_spec.rb
index 834dc85e..8c90f738 100644
--- a/spec/swagger_v2/reference_entity_spec.rb
+++ b/spec/swagger_v2/reference_entity_spec.rb
@@ -11,7 +11,7 @@ def self.entity_name
'SomethingCustom'
end
- expose :text, documentation: { type: 'string', desc: 'Content of something.' }
+ expose :text, documentation: { type: 'string', desc: 'Content of something.', required: true }
end
class Kind < Grape::Entity
@@ -19,8 +19,8 @@ def self.entity_name
'KindCustom'
end
- expose :title, documentation: { type: 'string', desc: 'Title of the kind.' }
- expose :something, documentation: { type: Something, desc: 'Something interesting.' }
+ expose :title, documentation: { type: 'string', desc: 'Title of the kind.', required: true }
+ expose :something, documentation: { type: Something, desc: 'Something interesting.', required: true }
end
class Base < Grape::Entity
@@ -30,11 +30,11 @@ def self.entity_name
"MyAPI::#{parts.last}"
end
- expose :title, documentation: { type: 'string', desc: 'Title of the parent.' }
+ expose :title, documentation: { type: 'string', desc: 'Title of the parent.', required: true }
end
class Child < Base
- expose :child, documentation: { type: 'string', desc: 'Child property.' }
+ expose :child, documentation: { type: 'string', desc: 'Child property.', required: true }
end
end
@@ -85,7 +85,7 @@ def app
'name' => 'something',
'description' => 'Something interesting.',
'type' => 'SomethingCustom',
- 'required' => false
+ 'required' => true
}]
expect(subject['definitions'].keys).to include 'SomethingCustom'