From a7d68c68cfde75efe2e8f90a8b7684a04d443ec8 Mon Sep 17 00:00:00 2001 From: Brian Underwood Date: Sun, 1 Jan 2023 20:54:13 +0100 Subject: [PATCH] Support Link header when fetching remote contexts --- lib/json/ld/document_loader/default.ex | 52 ++++++++- test/unit/document_loader/default_test.exs | 130 +++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/lib/json/ld/document_loader/default.ex b/lib/json/ld/document_loader/default.ex index 1f25fd3..3a294c3 100644 --- a/lib/json/ld/document_loader/default.ex +++ b/lib/json/ld/document_loader/default.ex @@ -19,8 +19,58 @@ defmodule JSON.LD.DocumentLoader.Default do @spec http_get(String.t()) :: {:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()} | {:error, any} defp http_get(url) do - HTTPoison.get(url, [accept: "application/ld+json"], follow_redirect: true) + with {:ok, response} <- + HTTPoison.get(url, [accept: "application/ld+json"], follow_redirect: true) do + case url_from_link_header(response) do + nil -> + {:ok, response} + + url -> + http_get(url) + end + end rescue e -> {:error, "HTTPoison failed: #{inspect(e)}"} end + + @spec url_from_link_header(HTTPoison.Response.t()) :: String.t() | nil + defp url_from_link_header(response) do + response.headers + |> Enum.find(fn {name, _} -> name == "Link" end) + |> case do + nil -> + nil + + {"Link", content} -> + with {url, props} <- parse_link_header(content) do + if match?(%{"rel" => "alternate", "type" => "application/ld+json"}, props) do + if String.starts_with?(url, "http") do + url + else + # Relative path + response.request.url + |> URI.parse() + |> Map.put(:path, url) + |> URI.to_string() + end + end + end + end + end + + @spec parse_link_header(String.t()) :: {String.t(), map()} | nil + defp parse_link_header(content) do + [first | prop_strings] = String.split(content, ~r/\s*;\s*/) + + with [_, url] <- Regex.run(~r/\A<([^>]+)>\Z/, first) do + props = + Map.new(prop_strings, fn prop_string -> + [_, key, value] = Regex.run(~r/\A([^=]+)=\"([^\"]+)\"\Z/, prop_string) + + {key, value} + end) + + {url, props} + end + end end diff --git a/test/unit/document_loader/default_test.exs b/test/unit/document_loader/default_test.exs index 248b771..876f7c3 100644 --- a/test/unit/document_loader/default_test.exs +++ b/test/unit/document_loader/default_test.exs @@ -87,6 +87,136 @@ defmodule JSON.LD.DocumentLoader.DefaultTest do assert JSON.LD.expand(local) == JSON.LD.expand(remote) end + test "loads remote context (with Link header, absolute URL)", %{local: local} do + bypass1 = Bypass.open(port: 44887) + bypass2 = Bypass.open(port: 44888) + + Bypass.expect(bypass1, fn conn -> + assert "GET" == conn.method + assert "/test1-context" == conn.request_path + + conn + |> Plug.Conn.put_resp_header("Content-Type", "text/html") + |> Plug.Conn.put_resp_header( + "Link", + "; rel=\"alternate\"; type=\"application/ld+json\"" + ) + |> Plug.Conn.resp(200, "Not here!") + end) + + Bypass.expect(bypass2, fn conn -> + assert "GET" == conn.method + assert "/test2-context" == conn.request_path + + context = %{ + "@context" => %{ + "homepage" => %{"@id" => "http://xmlns.com/foaf/0.1/homepage", "@type" => "@id"}, + "name" => "http://xmlns.com/foaf/0.1/name" + } + } + + Plug.Conn.resp(conn, 200, Jason.encode!(context)) + end) + + remote = + Jason.decode!(""" + { + "@context": "http://localhost:#{bypass1.port}/test1-context", + "name": "Manu Sporny", + "homepage": "http://manu.sporny.org/" + } + """) + + assert JSON.LD.expand(local) == JSON.LD.expand(remote) + end + + test "loads remote context (with Link header, relative path)", %{local: local} do + bypass = Bypass.open(port: 44887) + + Bypass.expect(bypass, fn conn -> + case conn.request_path do + "/test1-context" -> + assert "GET" == conn.method + + conn + |> Plug.Conn.put_resp_header("Content-Type", "text/html") + |> Plug.Conn.put_resp_header( + "Link", + "; rel=\"alternate\"; type=\"application/ld+json\"" + ) + |> Plug.Conn.resp(200, "Not here!") + + "/test2-context" -> + assert "GET" == conn.method + + context = %{ + "@context" => %{ + "homepage" => %{"@id" => "http://xmlns.com/foaf/0.1/homepage", "@type" => "@id"}, + "name" => "http://xmlns.com/foaf/0.1/name" + } + } + + Plug.Conn.resp(conn, 200, Jason.encode!(context)) + + other -> + raise "Unexpected request: #{inspect(other)}" + end + end) + + remote = + Jason.decode!(""" + { + "@context": "http://localhost:#{bypass.port}/test1-context", + "name": "Manu Sporny", + "homepage": "http://manu.sporny.org/" + } + """) + + assert JSON.LD.expand(local) == JSON.LD.expand(remote) + end + + test "loads remote context (invalid Link headers)", %{local: local} do + # Should ignore the invalid Link headers in all of these cases + + bypass1 = Bypass.open(port: 44887) + bypass2 = Bypass.open(port: 44888) + + [ + "; rel=\"alternate\"; type=\"text/html\"", + "; rel=\"unrecognized\"; type=\"application/ld+json\"", + "MALFORMED" + ] + |> Enum.each(fn link_header_content -> + Bypass.expect(bypass1, fn conn -> + assert "GET" == conn.method + assert "/test1-context" == conn.request_path + + context = %{ + "@context" => %{ + "homepage" => %{"@id" => "http://xmlns.com/foaf/0.1/homepage", "@type" => "@id"}, + "name" => "http://xmlns.com/foaf/0.1/name" + } + } + + conn + |> Plug.Conn.put_resp_header("Content-Type", "text/html") + |> Plug.Conn.put_resp_header("Link", link_header_content) + |> Plug.Conn.resp(200, Jason.encode!(context)) + end) + + remote = + Jason.decode!(""" + { + "@context": "http://localhost:#{bypass1.port}/test1-context", + "name": "Manu Sporny", + "homepage": "http://manu.sporny.org/" + } + """) + + assert JSON.LD.expand(local) == JSON.LD.expand(remote) + end) + end + test "loads remote context referring to other remote contexts", %{local: local} do bypass1 = Bypass.open(port: 44887) bypass2 = Bypass.open(port: 44888)