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'