Skip to content

Commit

Permalink
ported OpenApi fixes and enhancements to denormalize components use $…
Browse files Browse the repository at this point in the history
…ref when appropriate
  • Loading branch information
Josep M. Blanquer committed Jan 20, 2022
1 parent 28b7771 commit c7c5663
Show file tree
Hide file tree
Showing 10 changed files with 85 additions and 97 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/praxis/blueprint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def fieldset
end
end
include Attributor::Type
include Attributor::Container
include Attributor::Dumpable

extend Finalizable
Expand Down
2 changes: 1 addition & 1 deletion lib/praxis/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/praxis/docs/open_api/media_type_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/praxis/docs/open_api/operation_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 33 additions & 16 deletions lib/praxis/docs/open_api/schema_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions lib/praxis/docs/open_api/tag_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 37 additions & 70 deletions lib/praxis/docs/open_api_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -26,32 +27,45 @@ 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)
Mustermann.new(string).to_templates.first
end

def save!
raise 'You need to configure the root directory before saving (configure_root(<dir>))' 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
Expand All @@ -69,75 +83,28 @@ 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)
# version_info = infos_by_version[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])
Expand Down Expand Up @@ -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)|
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion lib/praxis/tasks/api_docs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion praxis.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit c7c5663

Please sign in to comment.