• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rdf-elixir / jsonld-ex / c94b8a72a4cd469227cb601eb6a583ffb4c03304

19 Jan 2026 07:13AM UTC coverage: 91.581% (+0.04%) from 91.542%
c94b8a72a4cd469227cb601eb6a583ffb4c03304

push

github

marcelotto
Add Elixir v1.19 and OTP v28.3 to CI

1773 of 1936 relevant lines covered (91.58%)

5497.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.14
/lib/json/ld/document_loader/remote_document.ex
1
defmodule JSON.LD.DocumentLoader.RemoteDocument do
2
  @moduledoc """
3
  Implementation of the JSON-LD 1.1 Remote Document and Context Retrieval specification.
4

5
  This module provides both:
6

7
  1. A struct representing remote documents as specified in https://www.w3.org/TR/json-ld11-api/#remotedocument
8
  2. The core implementation of remote document loading according to
9
     <https://www.w3.org/TR/json-ld11-api/#remote-document-and-context-retrieval> so that
10
     custom `JSON.LD.DocumentLoader` implementations can reuse this by calling `load/3` or
11
     implementing their own loading logic.
12

13
  ## Custom HTTP clients
14

15
  The default Tesla-based HTTP client is `JSON.LD.DocumentLoader.DefaultClient`.
16

17
  If you need a custom HTTP client with custom middleware, you can create your own module
18
  that implements a `client/3` function:
19

20
      defmodule MyCustomClient do
21
        def client(headers, url, options) do
22
          [
23
            {Tesla.Middleware.Headers, headers},
24
            # your custom middleware
25
          ]
26
          |> Tesla.client()
27
        end
28
      end
29

30
  and configure it as:
31

32
      config :json_ld, :http_client, MyCustomClient
33

34
  """
35

36
  defstruct [:context_url, :document_url, :document, :content_type, :profile]
37

38
  @type t :: %__MODULE__{
39
          context_url: String.t() | nil,
40
          document_url: String.t(),
41
          document: any,
42
          content_type: String.t(),
43
          profile: String.t() | nil
44
        }
45

46
  alias JSON.LD.Options
47
  alias JSON.LD.DocumentLoader.DefaultClient
48
  alias RDF.IRI
49

50
  def default_http_client do
51
    Application.get_env(:json_ld, :http_client, DefaultClient)
780✔
52
  end
53

54
  @doc """
55
  Loads a remote document from the given URL.
56

57
  According to <https://www.w3.org/TR/json-ld11-api/#remote-document-and-context-retrieval>
58
  """
59
  @spec load(String.t(), Options.convertible(), module) :: {:ok, t()} | {:error, any}
60
  def load(url, options \\ [], http_client \\ default_http_client()) do
61
    do_load(url, http_client, Options.new(options))
780✔
62
  end
63

64
  defp do_load(url, http_client, options, visited_urls \\ []) do
65
    if url in visited_urls do
800✔
66
      {:error,
67
       JSON.LD.Error.loading_document_failed("Circular reference detected in document loading")}
68
    else
69
      case http_get(http_client, url, options) do
800✔
70
        {:ok, %Tesla.Env{status: status} = response} when status in 200..299 ->
71
          # 3)
72
          document_url = response.url
760✔
73
          content_type = get_content_type(response.headers)
760✔
74
          profile = get_profile_from_content_type(response.headers)
760✔
75

76
          cond do
760✔
77
            # The HTTP Link Header is ignored for documents served as application/ld+json ...
78
            content_type == "application/ld+json" ->
79
              with {:ok, document} <- parse_json(response.body) do
515✔
80
                {:ok,
81
                 %__MODULE__{
82
                   document: document,
83
                   document_url: document_url,
84
                   content_type: content_type,
85
                   context_url: nil,
86
                   profile: profile
87
                 }}
88
              end
89

90
            # 5)
91
            content_type &&
×
92
                (String.starts_with?(content_type, "application/json") ||
245✔
93
                   String.contains?(content_type, "+json")) ->
245✔
94
              with {:ok, document} <- parse_json(response.body) do
220✔
95
                case find_context_links(response.headers) do
220✔
96
                  {:ok, nil} ->
195✔
97
                    {:ok,
98
                     %__MODULE__{
99
                       document: document,
100
                       document_url: document_url,
101
                       content_type: content_type,
102
                       context_url: nil,
103
                       profile: profile
104
                     }}
105

106
                  {:ok, context_url} ->
15✔
107
                    {:ok,
108
                     %__MODULE__{
109
                       document: document,
110
                       document_url: document_url,
111
                       content_type: content_type,
112
                       context_url: document_url |> IRI.merge(context_url) |> to_string(),
15✔
113
                       profile: profile
114
                     }}
115

116
                  {:error, _} = error ->
117
                    error
10✔
118
                end
119
              end
120

121
            # 4)
122
            true ->
25✔
123
              if alternate_url = find_alternate_link(response.headers) do
25✔
124
                document_url
125
                |> IRI.merge(alternate_url)
126
                |> to_string()
20✔
127
                |> do_load(http_client, options, [url | visited_urls])
20✔
128
              else
129
                # 6)
130
                {:error,
131
                 JSON.LD.Error.loading_document_failed(
132
                   "Retrieved resource's Content-Type is not JSON-compatible: #{content_type}"
5✔
133
                 )}
134
              end
135
          end
136

137
        {:ok, %{status: status}} ->
30✔
138
          {:error,
139
           JSON.LD.Error.loading_document_failed("HTTP request failed with status #{status}")}
30✔
140

141
        {:error, _} = error ->
142
          error
10✔
143
      end
144
    end
145
  end
146

147
  def load!(url, options \\ [], http_client \\ default_http_client()) do
148
    case load(url, options, http_client) do
×
149
      {:ok, remote_document} -> remote_document
×
150
      {:error, error} -> raise error
×
151
    end
152
  end
153

154
  # 2)
155
  def http_get(http_client, url, options) do
800✔
156
    options.request_profile
800✔
157
    |> build_headers()
158
    |> http_client.client(url, options)
159
    |> Tesla.get(url)
800✔
160
  rescue
161
    e -> {:error, JSON.LD.Error.loading_document_failed("HTTP request failed: #{inspect(e)}")}
10✔
162
  end
163

164
  defp build_headers(request_profile) do
800✔
165
    [
166
      {"accept",
167
       if request_profile do
200✔
168
         "application/ld+json;profile=\"#{request_profile |> List.wrap() |> Enum.join(" ")}\", application/json"
600✔
169
       else
170
         "application/ld+json, application/json"
171
       end}
172
    ]
173
  end
174

175
  defp get_content_type(headers) do
176
    case Enum.find(headers, fn {name, _} -> String.downcase(name) == "content-type" end) do
760✔
177
      {_, content_type} ->
178
        [base_type | _] = String.split(content_type, ";", parts: 2)
760✔
179
        String.trim(base_type)
760✔
180

181
      _ ->
×
182
        nil
183
    end
184
  end
185

186
  defp get_profile_from_content_type(headers) do
187
    case Enum.find(headers, fn {name, _} -> String.downcase(name) == "content-type" end) do
760✔
188
      {_, content_type} ->
189
        case Regex.run(~r/profile="?([^;"]+)"?/, content_type) do
760✔
190
          [_, profile] -> profile
10✔
191
          _ -> nil
750✔
192
        end
193

194
      _ ->
×
195
        nil
196
    end
197
  end
198

199
  defp find_context_links(headers) do
200
    headers
201
    |> Enum.filter(fn {name, value} ->
202
      String.downcase(name) == "link" &&
1,015✔
203
        String.contains?(String.downcase(value), "http://www.w3.org/ns/json-ld#context")
55✔
204
    end)
205
    |> Enum.flat_map(fn {_, value} ->
206
      value
207
      |> parse_link_headers()
208
      |> Enum.filter(fn {_, props} -> props["rel"] == "http://www.w3.org/ns/json-ld#context" end)
30✔
209
    end)
210
    |> case do
220✔
211
      [] -> {:ok, nil}
195✔
212
      [{url, _}] -> {:ok, url}
15✔
213
      _ -> {:error, JSON.LD.Error.multiple_context_link_headers()}
10✔
214
    end
215
  end
216

217
  defp find_alternate_link(headers) do
218
    headers
219
    |> Stream.filter(fn {name, _} -> String.downcase(name) == "link" end)
45✔
220
    |> Stream.flat_map(fn {_, value} ->
221
      value
222
      |> parse_link_headers()
223
      |> Stream.filter(fn {_, props} ->
20✔
224
        props["rel"] == "alternate" && props["type"] == "application/ld+json"
20✔
225
      end)
226
    end)
227
    |> Enum.find(fn {url, _} -> url end)
20✔
228
    |> case do
25✔
229
      nil -> nil
5✔
230
      {url, _} -> url
20✔
231
    end
232
  end
233

234
  defp parse_link_headers(value) do
235
    value
236
    |> String.split(",")
237
    |> Enum.map(&String.trim/1)
238
    |> Enum.map(&parse_link/1)
239
    |> Enum.reject(&is_nil/1)
50✔
240
  end
241

242
  defp parse_link(link_str) do
243
    with [url_part | param_parts] <- String.split(link_str, ";"),
55✔
244
         [_, url] <- Regex.run(~r/\A\s*<([^>]+)>\s*\Z/, url_part) do
55✔
245
      props =
55✔
246
        param_parts
247
        |> Enum.map(&String.trim/1)
248
        |> Enum.map(fn param ->
249
          case Regex.run(~r/\A([^=]+)=(?:"([^"]+)"|([^"]\S*))\Z/, param) do
75✔
250
            [_, key, value] -> {String.downcase(key), value}
75✔
251
            _ -> nil
×
252
          end
253
        end)
254
        |> Enum.reject(&is_nil/1)
75✔
255
        |> Map.new()
256

257
      {url, props}
258
    else
259
      _ -> nil
260
    end
261
  end
262

263
  defp parse_json(document) do
264
    case Jason.decode(document) do
735✔
265
      {:ok, _} = ok ->
266
        ok
730✔
267

268
      {:error, %Jason.DecodeError{} = error} ->
5✔
269
        {:error,
270
         JSON.LD.Error.loading_document_failed("JSON parsing failed: #{Exception.message(error)}")}
5✔
271
    end
272
  end
273
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc