# frozen_string_literal: true require 'prmd' require 'neatjson' module JsonSchemaDocs class Parser include Helpers attr_reader :processed_schema def initialize(schema, options) @options = options if schema.is_a?(Prmd::Schema) @schema = schema else # FIXME: Multiloader has issues: https://github.com/interagent/prmd/issues/279 # for now just always assume a JSON file data = Prmd::MultiLoader::Json.load_data(schema) @schema = Prmd::Schema.new(data) end @processed_schema = {} end def parse @schema['properties'].each_key do |key| resource, property = key, @schema['properties'][key] begin _, schemata = @schema.dereference(property) # establish condensed object description if schemata['properties'] && !schemata['properties'].empty? schemata['property_refs'] = [] refs = extract_schemata_refs(@schema, schemata['properties']).map { |v| v && v.split('/') } extract_attributes(@schema, schemata['properties']).each_with_index do |(key, type, description, example), i| property_ref = { type: type, description: description, example: example} if refs[i] && refs[i][1] == 'definitions' && refs[i][2] != resource property_ref[:name] = '[%s](#%s)' % [key, 'resource-' + refs[i][2]] else property_ref[:name] = key end schemata['property_refs'].push(property_ref) schemata['example'] = pretty_json(@schema.schemata_example(resource)) end end schemata['links'] ||= [] # establish full link description schemata['links'].each do |link, datum| link_path = build_link_path(@schema, link) response_example = link['response_example'] if link.has_key?('schema') && link['schema'].has_key?('properties') required, optional = Prmd::Link.new(link).required_and_optional_parameters unless required.empty? link_schema_required_properties = extract_attributes(@schema, required).map do |(name, type, description, example)| { name: name, type: type, description: description, example: example} end end unless optional.empty? link_schema_optional_properties = extract_attributes(@schema, optional).map do |(name, type, description, example)| { name: name, type: type, description: description, example: example} end end end link['link_path'] = link_path link['required_properties'] = link_schema_required_properties || [] link['optional_properties'] = link_schema_optional_properties || [] link['example'] = generate_example(link, link_path) link['response'] = { header: generate_response_header(response_example, link), example: generate_response_example(response_example, link, resource) } end @processed_schema[resource] = schemata rescue => e $stdout.puts("Error in resource: #{resource}") raise e end end @processed_schema end private def extract_attributes(schema, properties) attributes = [] _, properties = schema.dereference(properties) properties.each do |key, value| # found a reference to another element: _, value = schema.dereference(value) # include top level reference to nested things, when top level is nullable if value.has_key?('type') && value['type'].include?('null') && (value.has_key?('items') || value.has_key?('properties')) attributes << build_attribute(schema, key, value) end if value.has_key?('anyOf') descriptions = [] examples = [] anyof = value['anyOf'] anyof.each do |ref| _, nested_field = schema.dereference(ref) descriptions << nested_field['description'] if nested_field['description'] examples << nested_field['example'] if nested_field['example'] end # avoid repetition :} description = if descriptions.size > 1 descriptions.first.gsub!(/ of (this )?.*/, '') descriptions[1..-1].map { |d| d.gsub!(/unique /, '') } [descriptions[0...-1].join(', '), descriptions.last].join(' or ') else description = descriptions.last end example = [*examples].map { |e| "`#{e.to_json}`" }.join(' or ') attributes << [key, 'string', description, example] # found a nested object elsif value['properties'] nested = extract_attributes(schema, value['properties']) nested.each do |attribute| attribute[0] = "#{key}:#{attribute[0]}" end attributes.concat(nested) elsif array_with_nested_objects?(value['items']) if value['items']['properties'] nested = extract_attributes(schema, value['items']['properties']) nested.each do |attribute| attribute[0] = "#{key}/#{attribute[0]}" end attributes.concat(nested) end if value['items']['oneOf'] value['items']['oneOf'].each_with_index do |oneof, index| ref, oneof_definition = schema.dereference(oneof) oneof_name = ref ? ref.split('/').last : index nested = extract_attributes(schema, oneof_definition['properties']) nested.each do |attribute| attribute[0] = "#{key}/[#{oneof_name.upcase}].#{attribute[0]}" end attributes.concat(nested) end end # just a regular attribute else attributes << build_attribute(schema, key, value) end end attributes.map! { |key, type, description, example| if example.nil? && Prmd::DefaultExamples.key?(type) example = '`%s`' % Prmd::DefaultExamples[type].to_json end [key, type, description, example] } return attributes.sort end def extract_schemata_refs(schema, properties) ret = [] properties.keys.sort.each do |key| value = properties[key] ref, value = schema.dereference(value) if value['properties'] refs = extract_schemata_refs(schema, value['properties']) elsif value['items'] && value['items']['properties'] refs = extract_schemata_refs(schema, value['items']['properties']) else refs = [ref] end if value.has_key?('type') && value['type'].include?('null') && (value.has_key?('items') || value.has_key?('properties')) # A nullable object usually isn't a reference to another schema. It's # either not a reference at all, or it's a reference within the same # schema. Instead, the definition of the nullable object might contain # references to specific properties. # # If all properties refer to the same schema, we'll use that as the # reference. This might even overwrite an actual, intra-schema # reference. l = refs.map { |v| v && v.split('/')[0..2] } if l.uniq.size == 1 && l[0] != nil ref = l[0].join('/') end ret << ref end ret.concat(refs) end ret end def build_attribute(schema, key, value) description = value['description'] || '' if value['default'] description += "
**default:** `#{value['default'].to_json}`" end if value['minimum'] || value['maximum'] description += '
**Range:** `' if value['minimum'] comparator = value['exclusiveMinimum'] ? '<' : '<=' description += "#{value['minimum'].to_json} #{comparator} " end description += 'value' if value['maximum'] comparator = value['exclusiveMaximum'] ? '<' : '<=' description += " #{comparator} #{value['maximum'].to_json}" end description += '`' end if value['enum'] description += '
**one of:**' + [*value['enum']].map { |e| "`#{e.to_json}`" }.join(' or ') end if value['pattern'] description += "
**pattern:** `#{value['pattern']}`" end if value['minLength'] || value['maxLength'] description += '
**Length:** `' if value['minLength'] description += "#{value['minLength'].to_json}" end unless value['minLength'] == value['maxLength'] if value['maxLength'] unless value['minLength'] description += '0' end description += "..#{value['maxLength'].to_json}" else description += '..∞' end end description += '`' end if value.has_key?('example') example = if value['example'].is_a?(Hash) && value['example'].has_key?('oneOf') value['example']['oneOf'].map { |e| "`#{e.to_json}`" }.join(' or ') else "`#{value['example'].to_json}`" end elsif (value['type'] == ['array'] && value.has_key?('items')) || value.has_key?('enum') example = "`#{schema.schema_value_example(value).to_json}`" elsif value['type'].include?('null') example = '`null`' end type = if value['type'].include?('null') 'nullable ' else '' end type += (value['format'] || (value['type'] - ['null']).first) [key, type, description, example] end def build_link_path(schema, link) link['href'].gsub(%r|(\{\([^\)]+\)\})|) do |ref| ref = ref.gsub('%2F', '/').gsub('%23', '#').gsub(%r|[\{\(\)\}]|, '') ref_resource = ref.split('#/definitions/').last.split('/').first.gsub('-', '_') identity_key, identity_value = schema.dereference(ref) if identity_value.has_key?('anyOf') '{' + ref_resource + '_' + identity_value['anyOf'].map { |r| r['$ref'].split('/').last }.join('_or_') + '}' else '{' + ref_resource + '_' + identity_key.split('/').last + '}' end end end def array_with_nested_objects?(items) return unless items items['properties'] || items['oneOf'] end def generate_example(link, link_path) request = {} data = {} headers = {} path = link_path.gsub(/{([^}]*)}/) { |match| '$' + match.gsub(/[{}]/, '').upcase } get_params = [] if link.has_key?('schema') data = @schema.schema_example(link['schema']) if link['method'].upcase == 'GET' && !data.nil? get_params << Prmd::UrlGenerator.new({schema: @schema, link: link, options: @options[:prmd]}).url_params end end data = nil if data.empty? # same thing # fetch any headers if link['method'].upcase != 'GET' opts = @options[:prmd].dup headers = { 'Content-Type' => opts[:content_type] }.merge(opts[:http_header]) end # define initial request call if link['method'].upcase != 'GET' request = "-X #{link['method']} #{@schema.href}#{path}" else request = "#{@schema.href}#{path}" end # add data, if present if !data.nil? && link['method'].upcase != 'GET' data = "-d '#{pretty_json(data)}' \\" elsif !get_params.empty? && link['method'].upcase == 'GET' data = "-G #{get_params.join(" ss\\\n -d ")} \\" end { request: request, data: data, http_headers: headers } end def generate_response_header(response_example, link) return response_example['head'] if response_example header = 'HTTP/1.1' code = case link['rel'] when 'create' '201 Created' when 'empty' '202 Accepted' else '200 OK' end "#{header} #{code}" end def generate_response_example(response_example, link, resource) if response_example || link['rel'] != 'empty' if response_example response_example['body'] else if link['rel'] == 'empty' elsif link.has_key?('targetSchema') pretty_json(@schema.schema_example(link['targetSchema'])) elsif link['rel'] == 'instances' pretty_json([@schema.schemata_example(resource)]) else pretty_json(@schema.schemata_example(resource)) end end else nil end end def pretty_json(json) JSON.neat_generate(json, wrap: true, sort: true) end end end