diff --git a/lib/open_api_parser.rb b/lib/open_api_parser.rb index 5cf4b44..4c5800a 100644 --- a/lib/open_api_parser.rb +++ b/lib/open_api_parser.rb @@ -8,6 +8,7 @@ require "open_api_parser/document" require "open_api_parser/file_cache" require "open_api_parser/pointer" +require "open_api_parser/reference" require "open_api_parser/specification" require "open_api_parser/specification/endpoint" require "open_api_parser/specification/root" diff --git a/lib/open_api_parser/document.rb b/lib/open_api_parser/document.rb index 6084b38..6620044 100644 --- a/lib/open_api_parser/document.rb +++ b/lib/open_api_parser/document.rb @@ -19,49 +19,33 @@ def resolve private - def deeply_expand_refs(fragment, cur_path) - fragment, cur_path = expand_refs(fragment, cur_path) + def deeply_expand_refs(fragment, current_pointer) + fragment, current_pointer = expand_refs(fragment, current_pointer) if fragment.is_a?(Hash) fragment.reduce({}) do |hash, (k, v)| - hash.merge(k => deeply_expand_refs(v, "#{cur_path}/#{k}")) + hash.merge(k => deeply_expand_refs(v, "#{current_pointer}/#{k}")) end elsif fragment.is_a?(Array) - fragment.map { |e| deeply_expand_refs(e, cur_path) } + fragment.map { |e| deeply_expand_refs(e, current_pointer) } else fragment end end - def expand_refs(fragment, cur_path) + def expand_refs(fragment, current_pointer) if fragment.is_a?(Hash) && fragment.key?("$ref") - ref = fragment["$ref"] - - if ref.start_with?("file:") - expand_file(ref) + raw_uri = fragment["$ref"] + ref = OpenApiParser::Reference.new(raw_uri) + fully_resolved, referrent_document, referrent_pointer = + ref.resolve(@path, current_pointer, @content, @file_cache) + unless fully_resolved + expand_refs(referrent_document, referrent_pointer) else - expand_pointer(ref, cur_path) + [referrent_document, referrent_pointer] end else - [fragment, cur_path] - end - end - - def expand_file(ref) - relative_path = ref.split(":").last - absolute_path = File.expand_path(File.join("..", relative_path), @path) - - Document.resolve(absolute_path, @file_cache) - end - - def expand_pointer(ref, cur_path) - pointer = OpenApiParser::Pointer.new(ref) - - if pointer.exists_in_path?(cur_path) - { "$ref" => ref } - else - fragment = pointer.resolve(@content) - expand_refs(fragment, cur_path + pointer.escaped_pointer) + [fragment, current_pointer] end end end diff --git a/lib/open_api_parser/pointer.rb b/lib/open_api_parser/pointer.rb index a467245..dcbf4cc 100644 --- a/lib/open_api_parser/pointer.rb +++ b/lib/open_api_parser/pointer.rb @@ -12,16 +12,21 @@ def resolve(document) end end - def exists_in_path?(path) - path.include?(escaped_pointer) + def equal_or_ancestor_of?(other_pointer) + other_tokens = OpenApiParser::Pointer.new(other_pointer).escaped_pointer.split("/") + self_tokens = escaped_pointer.split("/") + perhaps_common_prefix = other_tokens[0...self_tokens.length] + perhaps_common_prefix == self_tokens end def escaped_pointer - if @raw_pointer.start_with?("#") - Addressable::URI.unencode(@raw_pointer[1..-1]) - else - @raw_pointer - end + fragment = + if @raw_pointer.start_with?("#") + @raw_pointer[1..-1] + else + @raw_pointer + end + Addressable::URI.unencode(fragment) end private diff --git a/lib/open_api_parser/reference.rb b/lib/open_api_parser/reference.rb new file mode 100644 index 0000000..96b3f1b --- /dev/null +++ b/lib/open_api_parser/reference.rb @@ -0,0 +1,54 @@ +module OpenApiParser + class Reference + def initialize(raw_uri) + @raw_uri = raw_uri + end + + def resolve(base_path, base_pointer, current_document, file_cache) + ref_uri = normalize_file_uri(Addressable::URI.parse(@raw_uri)) + base_uri = normalize_file_uri(Addressable::URI.parse(base_path)).omit(:fragment).normalize + resolved_uri = base_uri.join(ref_uri).omit(:fragment).normalize + + fully_expanded, referenced_document, base_pointer = + case resolved_uri.scheme + when nil, 'file' + if base_uri == resolved_uri + [false, current_document, base_pointer] + else + [true, OpenApiParser::Document.resolve(resolved_uri.path, file_cache), ''] + end + else + fail "$ref with scheme #{ref_uri.scheme} is not supported" + end + + if !ref_uri.fragment.nil? && ref_uri.fragment != '' + resolve_pointer(ref_uri.fragment, base_pointer, referenced_document, fully_expanded) + else + [fully_expanded, referenced_document, ''] + end + end + + private + + def normalize_file_uri(uri) + if uri.scheme == 'file' && uri.host.nil? + uri.merge(scheme: nil) + else + uri + end + end + + def resolve_pointer(raw_pointer, base_pointer, within_document, fully_expanded) + pointer = OpenApiParser::Pointer.new(raw_pointer) + + if pointer.equal_or_ancestor_of?(base_pointer) + referrent_document = { "$ref" => '#' + raw_pointer } + [true, referrent_document, base_pointer] + else + referrent_document = pointer.resolve(within_document) + referrent_pointer = pointer.escaped_pointer + [fully_expanded, referrent_document, referrent_pointer] + end + end + end +end diff --git a/spec/open_api_parser/document_spec.rb b/spec/open_api_parser/document_spec.rb index c66ed1a..0fad583 100644 --- a/spec/open_api_parser/document_spec.rb +++ b/spec/open_api_parser/document_spec.rb @@ -19,6 +19,10 @@ expect(json["person"]).to eq({ "name" => "Drew" }) + + expect(json["person_without_scheme"]).to eq({ + "name" => "Drew" + }) end it "resolves a mix of pointers and file references" do @@ -32,6 +36,8 @@ expect(json["person"]["stats"]).to eq({ "age" => 34 }) + + expect(json["person_greeting"]).to eq("Drew") end end diff --git a/spec/open_api_parser/pointer_spec.rb b/spec/open_api_parser/pointer_spec.rb index fdcf796..a1f821d 100644 --- a/spec/open_api_parser/pointer_spec.rb +++ b/spec/open_api_parser/pointer_spec.rb @@ -17,22 +17,23 @@ describe "resolve" do it "works with RFC examples" do resolutions = { - "" => DOCUMENT, - "/foo" => ["bar", "baz"], - "/foo/0" => "bar", - "/" => 0, - "/a~1b" => 1, - "/c%d" => 2, - "/e^f" => 3, - "/g|h" => 4, - "/i\\j" => 5, - "/k\"l" => 6, - "/ " => 7, - "/m~0n" => 8, + "#" => DOCUMENT, + "#/foo" => ["bar", "baz"], + "#/foo/0" => "bar", + "#/" => 0, + "#/a~1b" => 1, + "#/c%d" => 2, + "#/e^f" => 3, + "#/g|h" => 4, + "#/i\\j" => 5, + "#/k\"l" => 6, + "#/ " => 7, + "#/m~0n" => 8, } resolutions.each do |pointer, expected| expect(OpenApiParser::Pointer.new(pointer).resolve(DOCUMENT)).to eq(expected) + expect(OpenApiParser::Pointer.new(pointer[1..-1]).resolve(DOCUMENT)).to eq(expected) end end @@ -54,6 +55,7 @@ resolutions.each do |pointer, expected| expect(OpenApiParser::Pointer.new(pointer).resolve(DOCUMENT)).to eq(expected) + expect(OpenApiParser::Pointer.new(pointer[1..-1]).resolve(DOCUMENT)).to eq(expected) end end end diff --git a/spec/open_api_parser/reference_spec.rb b/spec/open_api_parser/reference_spec.rb new file mode 100644 index 0000000..3a1cb77 --- /dev/null +++ b/spec/open_api_parser/reference_spec.rb @@ -0,0 +1,307 @@ +require "spec_helper" + +RSpec.describe OpenApiParser::Reference do + let(:file_cache) { OpenApiParser::FileCache.new } + + module PathHelpers + def cwd_relative(path_relative_to_project_root) + abs_path = Pathname.new(absolute(path_relative_to_project_root)) + abs_path.relative_path_from(Pathname.pwd).to_s + end + + def absolute(path_relative_to_project_root) + File.join(project_root, path_relative_to_project_root) + end + + def project_root + @project_root ||= File.expand_path(File.join('..', '..', '..'), __FILE__) + end + end + extend PathHelpers + include PathHelpers + + describe "#resolve" do + it "can be called repeatedly" do + ref = OpenApiParser::Reference.new('') + expect do + ref.resolve("http:", '', {}, file_cache) + end.to raise_error(Addressable::URI::InvalidURIError) + expect(ref.resolve('', '', {}, file_cache)).to eq [false, {}, ""] + end + + describe "supported schemes" do + it "supports the file scheme with relative path" do + ref = OpenApiParser::Reference.new('file:nested/person.yaml') + _, referrent_doc, _ = ref.resolve(cwd_relative("spec/resources/valid_spec.yaml"), '', {}, file_cache) + expect(referrent_doc).to eq({"name" => "Drew"}) + end + + it "supports the file scheme with absolute path" do + ref = OpenApiParser::Reference.new('file:' + absolute('spec/resources/nested/person.yaml')) + _, referrent_doc, _ = ref.resolve(cwd_relative("spec/resources/valid_spec.yaml"), '', {}, file_cache) + expect(referrent_doc).to eq({"name" => "Drew"}) + end + + it "interprets an empty scheme as a file path" do + ref = OpenApiParser::Reference.new('nested/person.yaml') + _, referrent_doc, _ = ref.resolve(cwd_relative("spec/resources/valid_spec.yaml"), '', {}, file_cache) + expect(referrent_doc).to eq({"name" => "Drew"}) + end + + it "does not support URI schemes other than file" do + ref = OpenApiParser::Reference.new('http://example.com/') + expect do + ref.resolve('', '', {}, file_cache) + end.to raise_error(/scheme http is not supported/) + end + end + + STANDARD_DOCUMENT = { + "foo" => "bar", + "base_pointer" => "boo", + } + + context "given an invalid base uri" do + it "raises an error" do + ref = OpenApiParser::Reference.new(cwd_relative('spec/resources/nested/person.yaml')) + expect do + ref.resolve("http:", '', {}, file_cache) + end.to raise_error(Addressable::URI::InvalidURIError) + end + end + + context "given a non-existent base uri" do + it "does not check for base uri's existence" do + ref = OpenApiParser::Reference.new('nested/person.yaml') + bad_base_path = cwd_relative("spec/resources/this-should-never-exist.lmay") + _, referrent_doc, _ = ref.resolve(bad_base_path, '', {}, file_cache) + expect(referrent_doc).to eq({"name" => "Drew"}) + end + end + + context "given a non-existent ref path" do + it "raises an error" do + ref = OpenApiParser::Reference.new('nested/this-should-never-exist.lmay') + expect do + ref.resolve(cwd_relative("spec/resources/valid_spec.yaml"), '', {}, file_cache) + end.to raise_error(Errno::ENOENT) + end + end + + describe "path resolution" do + [ + ["nested/person.yaml", cwd_relative("spec/resources/valid_spec.yaml")], + ["nested/person.yaml", absolute("spec/resources/valid_spec.yaml")], + [absolute("spec/resources/nested/person.yaml"), cwd_relative("spec/resources/valid_spec.yaml")], + [absolute("spec/resources/nested/person.yaml"), absolute("spec/resources/valid_spec.yaml")], + ].each do |ref_uri,base_uri| + context "given $ref #{ref_uri} and base_uri #{base_uri}" do + it "resolves successfully" do + ref = OpenApiParser::Reference.new(ref_uri) + _, referrent_doc, _ = ref.resolve(base_uri, '', {}, file_cache) + expect(referrent_doc).to eq({"name" => "Drew"}) + end + end + end + + context "given a $ref path the same as the base path" do + it "reuses the current document" do + expect(YAML).to_not receive(:load) + document = {"current" => true} + ref = OpenApiParser::Reference.new('person.yaml') + _, referrent_doc, _ = ref.resolve('person.yaml', '', document, file_cache) + expect(referrent_doc).to eq(document) + end + end + end + + describe "pointer resolution" do + context "given a deeply nested document" do + let(:base_path) { cwd_relative("spec/resources/standard.yaml") } + let(:ref_path) { "" } + let(:document) { + { + "base" => "hello", + "parent" => { + "base" => { + }, + "b" => "parent b", + "base-2" => "parent base-2", + } + } + } + [ + ["/parent/base", "#/parent", {"$ref" => "#/parent"}, "/parent/base"], + ["/parent/base", "#/base", "hello", "/base"], + ["/parent/base", "#/parent/b", "parent b", "/parent/b"], + ["/parent/base", "#/parent/base", {"$ref" => "#/parent/base"}, "/parent/base"], + ["/parent/base", "#/parent/base-2", "parent base-2", "/parent/base-2"], + ].each do |base_pointer,ref_pointer,expected_doc,expected_pointer| + it "resolves '#{ref_pointer}' as expected when base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + _, referrent_doc, referrent_pointer = ref.resolve(base_path, base_pointer, document, file_cache) + + expect(referrent_doc).to eq(expected_doc) + expect(referrent_pointer).to eq(expected_pointer) + end + end + end + + context "given a $ref with an empty path" do + let(:document) { STANDARD_DOCUMENT } + let(:base_path) { cwd_relative("spec/resources/standard.yaml") } + let(:ref_path) { "" } + [ + ["", "", STANDARD_DOCUMENT, ""], + ["", "#/foo", "bar", "/foo"], + ["", "#/base_pointer", "boo", "/base_pointer"], + ["/base_pointer", "", STANDARD_DOCUMENT, ""], + ["/base_pointer", "#/foo", "bar", "/foo"], + ["/base_pointer", "#/base_pointer", {"$ref" => "#/base_pointer"}, "/base_pointer"], + ].each do |base_pointer,ref_pointer,expected_doc,expected_pointer| + it "resolves '#{ref_pointer}' as expected when base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + _, referrent_doc, referrent_pointer = ref.resolve(base_path, base_pointer, document, file_cache) + + expect(referrent_doc).to eq(expected_doc) + expect(referrent_pointer).to eq(expected_pointer) + end + end + + it "raises an error if referrent fragment does not exist" do + ref = OpenApiParser::Reference.new('#/non-existent-token') + expect do + ref.resolve('', '', document, file_cache) + end.to raise_error(KeyError) + end + end + + context "given a $ref whose path is the same as base_uri" do + let(:document) { STANDARD_DOCUMENT } + let(:base_path) { cwd_relative("spec/resources/standard.yaml") } + let(:ref_path) { "standard.yaml" } + + before do + expect(YAML).to_not( + receive(:load_file).with(base_path)) + end + + [ + ["", "", STANDARD_DOCUMENT, ""], + ["", "#/foo", "bar", "/foo"], + ["", "#/base_pointer", "boo", "/base_pointer"], + ["/base_pointer", "", STANDARD_DOCUMENT, ""], + ["/base_pointer", "#/foo", "bar", "/foo"], + ["/base_pointer", "#/base_pointer", {"$ref" => "#/base_pointer"}, "/base_pointer"], + ].each do |base_pointer,ref_pointer,expected_doc,expected_pointer| + it "resolves '#{ref_pointer}' as expected when base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + _, referrent_doc, referrent_pointer = ref.resolve(base_path, base_pointer, document, file_cache) + + expect(referrent_doc).to eq(expected_doc) + expect(referrent_pointer).to eq(expected_pointer) + end + end + end + + context "given a $ref whose path is different than the base_uri" do + let(:document) { STANDARD_DOCUMENT } + let(:base_path) { cwd_relative("spec/resources/standard.yaml") } + let(:ref_path) { "another_standard.yaml" } + + before do + expect(YAML).to( + receive(:load_file).with(cwd_relative("spec/resources/another_standard.yaml")).and_return(document)) + end + [ + ["", "", STANDARD_DOCUMENT, ""], + ["", "#/foo", "bar", "/foo"], + ["", "#/base_pointer", "boo", "/base_pointer"], + ["/base_pointer", "", STANDARD_DOCUMENT, ""], + ["/base_pointer", "#/foo", "bar", "/foo"], + ["/base_pointer", "#/base_pointer", "boo", "/base_pointer"], + ].each do |base_pointer,ref_pointer,expected_doc,expected_pointer| + it "resolves '#{ref_pointer}' as expected when base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + _, referrent_doc, referrent_pointer = ref.resolve(base_path, base_pointer, document, file_cache) + + expect(referrent_doc).to eq(expected_doc) + expect(referrent_pointer).to eq(expected_pointer) + end + end + end + end + + describe 'return value for fully_expanded' do + let(:document) { STANDARD_DOCUMENT } + + context "given an empty ref path" do + let(:base_path) { cwd_relative('spec/resources/standard.yaml') } + let(:ref_path) { '' } + + [ + ['', '', false], + ['', '#/foo', false], + ['/base_pointer', '', false], + ['/base_pointer', '#/foo', false], + ['/base_pointer', '#/base_pointer', true], + ].each do |base_pointer,ref_pointer,expected| + it "is #{expected} when $ref pointer is '#{ref_pointer}' and base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + fully_expanded, *_rest = ref.resolve(base_path, base_pointer, document, file_cache) + expect(fully_expanded).to be expected + end + end + end + + context "given a ref path same as the base path" do + let(:base_path) { cwd_relative('spec/resources/standard.yaml') } + let(:ref_path) { 'standard.yaml' } + + [ + ['', '', false], + ['', '#/foo', false], + ['/base_pointer', '', false], + ['/base_pointer', '#/foo', false], + ['/base_pointer', '#/base_pointer', true], + ].each do |base_pointer,ref_pointer,expected| + it "is #{expected} when $ref pointer is '#{ref_pointer}' and base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + fully_expanded, *_rest = ref.resolve(base_path, base_pointer, document, file_cache) + expect(fully_expanded).to be expected + end + end + end + + context "given a ref path different than the base path" do + let(:base_path) { cwd_relative('spec/resources/standard.yaml') } + let(:ref_path) { 'another_standard.yaml' } + + before do + expect(YAML).to( + receive(:load_file).with(cwd_relative("spec/resources/another_standard.yaml")).and_return(document)) + end + [ + ['', '', true], + ['', '#/foo', true], + ['/base_pointer', '', true], + ['/base_pointer', '#/foo', true], + ['/base_pointer', '#/base_pointer', true], + ].each do |base_pointer,ref_pointer,expected| + it "is #{expected} when $ref pointer is '#{ref_pointer}' and base pointer is '#{base_pointer}'" do + ref_uri = ref_path + ref_pointer + ref = OpenApiParser::Reference.new(ref_uri) + fully_expanded, *_rest = ref.resolve(base_path, base_pointer, document, file_cache) + expect(fully_expanded).to be expected + end + end + end + end + end +end diff --git a/spec/resources/file_reference_example.yaml b/spec/resources/file_reference_example.yaml index be35a4d..11c5a86 100644 --- a/spec/resources/file_reference_example.yaml +++ b/spec/resources/file_reference_example.yaml @@ -1,2 +1,4 @@ person: $ref: "file:nested/person.yaml" +person_without_scheme: + $ref: "nested/person.yaml" diff --git a/spec/resources/mixed_reference_example.yaml b/spec/resources/mixed_reference_example.yaml index 179e7a8..54aeb47 100644 --- a/spec/resources/mixed_reference_example.yaml +++ b/spec/resources/mixed_reference_example.yaml @@ -1,2 +1,4 @@ person: $ref: "file:nested/mixed_person.yaml" +person_greeting: + $ref: "file:nested/mixed_person.yaml#/greeting/hi"