Skip to content

Commit

Permalink
Support Link header when fetching remote contexts
Browse files Browse the repository at this point in the history
  • Loading branch information
cheerfulstoic committed Jan 1, 2023
1 parent 48efc4f commit 6f5d47d
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 1 deletion.
51 changes: 50 additions & 1 deletion lib/json/ld/document_loader/default.ex
Expand Up @@ -19,8 +19,57 @@ 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
125 changes: 125 additions & 0 deletions test/unit/document_loader/default_test.exs
Expand Up @@ -87,6 +87,131 @@ 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", "<http://localhost:#{bypass2.port}/test2-context>; rel=\"alternate\"; type=\"application/ld+json\"")
|> Plug.Conn.resp(200, "<html>Not here!</html>")
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", "</test2-context>; rel=\"alternate\"; type=\"application/ld+json\"")
|> Plug.Conn.resp(200, "<html>Not here!</html>")

"/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)

[
"<http://localhost:#{bypass2.port}/test2-context>; rel=\"alternate\"; type=\"text/html\"",
"<http://localhost:#{bypass2.port}/test2-context>; 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)
Expand Down

0 comments on commit 6f5d47d

Please sign in to comment.