From c7c5663df34249107063339f4c404eade0b36dd9 Mon Sep 17 00:00:00 2001 From: "Josep M. Blanquer" Date: Thu, 20 Jan 2022 09:51:41 +0100 Subject: [PATCH] ported OpenApi fixes and enhancements to denormalize components use $ref when appropriate --- CHANGELOG.md | 4 +- lib/praxis/blueprint.rb | 1 + lib/praxis/collection.rb | 2 +- lib/praxis/docs/open_api/media_type_object.rb | 4 +- lib/praxis/docs/open_api/operation_object.rb | 2 +- lib/praxis/docs/open_api/schema_object.rb | 49 +++++--- lib/praxis/docs/open_api/tag_object.rb | 8 +- lib/praxis/docs/open_api_generator.rb | 107 ++++++------------ lib/praxis/tasks/api_docs.rb | 3 +- praxis.gemspec | 2 +- 10 files changed, 85 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a310b55..552d89a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Praxis Changelog ## next - +* Open API Generation enhancements: + * Fixed type discovery (where some types wouldn't be included in the output) + * Changed the generation to output named types into components, and use `$ref` to point to them whenever appropriate ## 2.0.pre.19 * Introduced a new DSL for the `FilteringParams` type that allows filters for common attributes in your Media Types: * The new `any` DSL allows you to define which final leaf attribute to always allow, and with which operators and/or fuzzy restrictions. diff --git a/lib/praxis/blueprint.rb b/lib/praxis/blueprint.rb index 519374d4..2e65907f 100644 --- a/lib/praxis/blueprint.rb +++ b/lib/praxis/blueprint.rb @@ -38,6 +38,7 @@ def fieldset end end include Attributor::Type + include Attributor::Container include Attributor::Dumpable extend Finalizable diff --git a/lib/praxis/collection.rb b/lib/praxis/collection.rb index 08a46421..23d5074e 100644 --- a/lib/praxis/collection.rb +++ b/lib/praxis/collection.rb @@ -37,7 +37,7 @@ def self.as_json_schema(**_args) the_type = @attribute&.type || member_type { type: json_schema_type, - items: { '$ref': "#/components/schemas/#{the_type.id}" } + items: the_type.as_json_schema } end end diff --git a/lib/praxis/docs/open_api/media_type_object.rb b/lib/praxis/docs/open_api/media_type_object.rb index 4eaec974..af1726f6 100644 --- a/lib/praxis/docs/open_api/media_type_object.rb +++ b/lib/praxis/docs/open_api/media_type_object.rb @@ -27,9 +27,9 @@ def self.create_content_attribute_helper(type:, example_payload:, example_handle return {} if type.is_a? SimpleMediaType # NOTE: skip if it's a SimpleMediaType?? ... is that correct? the_schema = if type.anonymous? || !(type < Praxis::MediaType) # Avoid referencing custom/simple Types? (i.e., just MTs) - SchemaObject.new(info: type).dump_schema + SchemaObject.new(info: type).dump_schema(shallow: false, allow_ref: false) else - { '$ref': "#/components/schemas/#{type.id}" } + SchemaObject.new(info: type).dump_schema(shallow: true, allow_ref: true) end if example_payload diff --git a/lib/praxis/docs/open_api/operation_object.rb b/lib/praxis/docs/open_api/operation_object.rb index 73cfb80d..60d7d86b 100644 --- a/lib/praxis/docs/open_api/operation_object.rb +++ b/lib/praxis/docs/open_api/operation_object.rb @@ -23,7 +23,6 @@ def dump all_tags = tags + action.traits h = { summary: action.name.to_s, - description: action.description, # externalDocs: {}, # TODO/FIXME operationId: id, responses: ResponsesObject.new(responses: action.responses).dump @@ -32,6 +31,7 @@ def dump # security: [{}] # servers: [{}] } + h[:description] = action.description if action.description h[:tags] = all_tags.uniq unless all_tags.empty? h[:parameters] = all_parameters unless all_parameters.empty? h[:requestBody] = RequestBodyObject.new(attribute: action.payload).dump if action.payload diff --git a/lib/praxis/docs/open_api/schema_object.rb b/lib/praxis/docs/open_api/schema_object.rb index f2e95367..72b28918 100644 --- a/lib/praxis/docs/open_api/schema_object.rb +++ b/lib/praxis/docs/open_api/schema_object.rb @@ -5,33 +5,50 @@ module Docs module OpenApi class SchemaObject # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schema-object - attr_reader :type, :attribute + attr_reader :type def initialize(info:) # info could be an attribute ... or a type? - if info.is_a? Attributor::Attribute - @attribute = info - else - @type = info - end + @type = info.is_a?(Attributor::Attribute) ? info.type : info + + # Mediatypes have the description method, lower types don't + @description = @type.description if @type.respond_to?(:description) + @description ||= info.options[:description] if info.respond_to?(:options) + @collection = type.respond_to?(:member_type) end def dump_example - ex = \ - if attribute - attribute.example - else - type.example - end + ex = type.example ex.respond_to?(:dump) ? ex.dump : ex end - def dump_schema - if attribute - attribute.as_json_schema(shallow: true, example: nil) + def dump_schema(shallow: false, allow_ref: false) + # We will dump schemas for mediatypes by simply creating a reference to the components' section + if type < Attributor::Container + if (type < Praxis::Blueprint || type < Attributor::Model) && allow_ref && !type.anonymous? + # TODO: Do we even need a description? + h = @description ? { 'description' => @description } : {} + + Praxis::Docs::OpenApiGenerator.instance.register_seen_component(type) + h.merge('$ref' => "#/components/schemas/#{type.id}") + elsif @collection + items = OpenApi::SchemaObject.new(info: type.member_type).dump_schema(allow_ref: allow_ref, shallow: false) + h = @description ? { description: @description } : {} + h.merge(type: 'array', items: items) + else + # Object + props = type.attributes.each_with_object({}) do |(name, definition), hash| + hash[name] = OpenApi::SchemaObject.new(info: definition).dump_schema(allow_ref: true, shallow: shallow) + end + { type: :object, properties: props } # TODO: Example? + end else - type.as_json_schema(shallow: true, example: nil) + # OpenApi::SchemaObject.new(info:target).dump_schema(allow_ref: allow_ref, shallow: shallow) + # TODO...we need to make sure we can use refs in the underlying components after the first level... + # ... maybe we need to loop over the attributes if it's an object/struct?... + type.as_json_schema(shallow: shallow, example: nil) end + # # TODO: FIXME: return a generic object type if the passed info was weird. # return { type: :object } unless info diff --git a/lib/praxis/docs/open_api/tag_object.rb b/lib/praxis/docs/open_api/tag_object.rb index 018c0e00..46ebb9cf 100644 --- a/lib/praxis/docs/open_api/tag_object.rb +++ b/lib/praxis/docs/open_api/tag_object.rb @@ -12,11 +12,11 @@ def initialize(name:, description:) end def dump - { - name: name, - description: description + h = description ? { description: description } : {} + h.merge( + name: name # externalDocs: ???, - } + ) end end end diff --git a/lib/praxis/docs/open_api_generator.rb b/lib/praxis/docs/open_api_generator.rb index 51a51565..f6bc7768 100644 --- a/lib/praxis/docs/open_api_generator.rb +++ b/lib/praxis/docs/open_api_generator.rb @@ -9,6 +9,7 @@ module Praxis module Docs class OpenApiGenerator require 'active_support/core_ext/enumerable' # For index_by + include Singleton API_DOCS_DIRNAME = 'docs/openapi' EXCLUDED_TYPES_FROM_OUTPUT = Set.new([ @@ -26,7 +27,7 @@ class OpenApiGenerator Attributor::URI ]).freeze - attr_reader :resources_by_version, :types_by_id, :infos_by_version, :doc_root_dir + attr_reader :resources_by_version, :infos_by_version, :doc_root_dir # substitutes ":params_like_so" for {params_like_so} def self.templatize_url(string) @@ -34,24 +35,37 @@ def self.templatize_url(string) end def save! + raise 'You need to configure the root directory before saving (configure_root())' unless @root + initialize_directories # Restrict the versions listed in the index file to the ones for which we have at least 1 resource write_index_file(for_versions: resources_by_version.keys) resources_by_version.each_key do |version| + @seen_components_for_current_version = Set.new write_version_file(version) end end - def initialize(root) + def initialize require 'yaml' - @root = root + @resources_by_version = Hash.new do |h, k| h[k] = Set.new end - + # List of types that we have seen/marked as necessary to list in the components/schemas section + # These should contain any mediatype define in the versioned controllers plus any type + # for which we've explicitly rendered a $ref schema + @seen_components_for_current_version = Set.new @infos = ApiDefinition.instance.infos collect_resources - collect_types + end + + def configure_root(root) + @root = root + end + + def register_seen_component(type) + @seen_components_for_current_version.add(type) end private @@ -69,63 +83,16 @@ def collect_resources end end - def collect_types - @types_by_id = ObjectSpace.each_object(Class).select do |obj| - obj < Attributor::Type - end.index_by(&:id) - end - def write_index_file(for_versions:) # TODO. create a simple html file that can link to the individual versions available end - def scan_types_for_version(version, dumped_resources) - found_media_types = resources_by_version[version].select(&:media_type).collect { |r| r.media_type.describe } - + # TODO: Change this function name to scan_default_mediatypes... + def collect_default_mediatypes(endpoints) # We'll start by processing the rendered mediatypes - processed_types = Set.new(resources_by_version[version].select do |r| - r.media_type && !r.media_type.is_a?(Praxis::SimpleMediaType) + Set.new(endpoints.select do |endpoint| + endpoint.media_type && !endpoint.media_type.is_a?(Praxis::SimpleMediaType) end.collect(&:media_type)) - - newfound = Set.new - found_media_types.each do |mt| - newfound += scan_dump_for_types({ type: mt }, processed_types) - end - # Then will process the rendered resources (noting) - newfound += scan_dump_for_types(dumped_resources, Set.new) - - # At this point we've done a scan of the dumped resources and mediatypes. - # In that scan we've discovered a bunch of types, however, many of those might have appeared in the JSON - # rendered in just shallow mode, so it is not guaranteed that we've seen all the available types. - # For that we'll do a (non-shallow) dump of all the types we found, and scan them until the scans do not - # yield types we haven't seen before - until newfound.empty? - dumped = newfound.collect(&:describe) - processed_types += newfound - newfound = scan_dump_for_types(dumped, processed_types) - end - processed_types - end - - def scan_dump_for_types(data, processed_types) - newfound_types = Set.new - case data - when Array - data.collect { |item| newfound_types += scan_dump_for_types(item, processed_types) } - when Hash - if data.key?(:type) && data[:type].is_a?(Hash) && (%i[id name family] - data[:type].keys).empty? - type_id = data[:type][:id] - unless type_id.nil? || type_id == Praxis::SimpleMediaType.id # SimpleTypes shouldn't be collected - unless types_by_id[type_id] - raise "Error! We have detected a reference to a 'Type' with id='#{type_id}' which is not derived from Attributor::Type" \ - ' Document generation cannot proceed.' - end - newfound_types << types_by_id[type_id] unless processed_types.include? types_by_id[type_id] - end - end - data.values.map { |item| newfound_types += scan_dump_for_types(item, processed_types) } - end - newfound_types end def write_version_file(version) @@ -133,11 +100,11 @@ def write_version_file(version) # # Hack, let's "inherit/copy" all traits of a version from the global definition # # Eventually traits should be defined for a version (and inheritable from global) so we'll emulate that here # version_info[:traits] = infos_by_version[:traits] - dumped_resources = dump_resources(resources_by_version[version]) - processed_types = scan_types_for_version(version, dumped_resources) + # We'll for sure include any of the default mediatypes in the endpoints for this version + @seen_components_for_current_version.merge(collect_default_mediatypes(resources_by_version[version])) # Here we have: - # processed types: which includes mediatypes and normal types...real classes + # processed types: which includes default mediatypes for the processed endpoints # processed resources for this version: resources_by_version[version] info_object = OpenApi::InfoObject.new(version: version, api_definition_info: @infos[version]) @@ -168,8 +135,8 @@ def write_version_file(version) end full_data[:tags] = full_data[:tags] + tags_for_traits unless tags_for_traits.empty? - # Include only MTs (i.e., not custom types or simple types...) - component_schemas = reusable_schema_objects(processed_types.select { |t| t < Praxis::MediaType }) + # Include only MTs and Blueprints (i.e., no simple types...) + component_schemas = add_component_schemas(@seen_components_for_current_version.clone, {}) # 3- Then adding all of the top level Mediatypes...so we can present them at the bottom, otherwise they don't show tags_for_mts = component_schemas.map do |(name, _info)| @@ -251,16 +218,16 @@ def normalize_media_types(mtis) end end - def reusable_schema_objects(types) - types.each_with_object({}) do |(type), accum| - the_type = \ - if type.respond_to? :as_json_schema - type - else # If it is a blueprint ... for now, it'd be through the attribute - type.attribute - end - accum[type.id] = the_type.as_json_schema(shallow: false) + def add_component_schemas(types_to_add, components_hash) + initial = @seen_components_for_current_version.dup + types_to_add.each_with_object(components_hash) do |(type), accum| + # For components, we want the first level to be fully dumped (only references below that) + accum[type.id] = OpenApi::SchemaObject.new(info: type).dump_schema(allow_ref: false, shallow: false) end + newfound = @seen_components_for_current_version - initial + # Process the new types if they have discovered + add_component_schemas(newfound, components_hash) unless newfound.empty? + components_hash end def convert_to_parameter_object(params) diff --git a/lib/praxis/tasks/api_docs.rb b/lib/praxis/tasks/api_docs.rb index c0592422..a79f3523 100644 --- a/lib/praxis/tasks/api_docs.rb +++ b/lib/praxis/tasks/api_docs.rb @@ -7,7 +7,8 @@ require 'fileutils' Praxis::Blueprint.caching_enabled = false - generator = Praxis::Docs::OpenApiGenerator.new(Dir.pwd) + generator = Praxis::Docs::OpenApiGenerator.instance + generator.configure_root(Dir.pwd) generator.save! end diff --git a/praxis.gemspec b/praxis.gemspec index 3b60d78f..6f337463 100644 --- a/praxis.gemspec +++ b/praxis.gemspec @@ -51,6 +51,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rspec-collection_matchers', '~> 1' spec.add_development_dependency 'rspec-its', '~> 1' # Just for the query selector extensions etc... - spec.add_development_dependency 'activerecord', '> 4','< 7' + spec.add_development_dependency 'activerecord', '> 4', '< 7' spec.add_development_dependency 'sequel', '~> 5' end