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

rdf-elixir / jsonld-ex / de3ca9b1dae3a8dffda3dec6d69d62a58ee3622e

10 Apr 2025 03:31PM UTC coverage: 91.542%. Remained the same
de3ca9b1dae3a8dffda3dec6d69d62a58ee3622e

push

github

marcelotto
Remove unused HTML versions of test manifests

1775 of 1939 relevant lines covered (91.54%)

4387.12 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
        use Tesla
22

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

32
  and configure it as:
33

34
      config :json_ld, :http_client, MyCustomClient
35

36
  """
37

38
  defstruct [:context_url, :document_url, :document, :content_type, :profile]
39

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

48
  alias JSON.LD.Options
49
  alias JSON.LD.DocumentLoader.DefaultClient
50
  alias RDF.IRI
51

52
  def default_http_client do
53
    Application.get_env(:json_ld, :http_client, DefaultClient)
624✔
54
  end
55

56
  @doc """
57
  Loads a remote document from the given URL.
58

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

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

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

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

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

118
                  {:error, _} = error ->
119
                    error
8✔
120
                end
121
              end
122

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

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

143
        {:error, _} = error ->
144
          error
8✔
145
      end
146
    end
147
  end
148

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

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

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

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

183
      _ ->
×
184
        nil
185
    end
186
  end
187

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

196
      _ ->
×
197
        nil
198
    end
199
  end
200

201
  defp find_context_links(headers) do
202
    headers
203
    |> Enum.filter(fn {name, value} ->
204
      String.downcase(name) == "link" &&
812✔
205
        String.contains?(String.downcase(value), "http://www.w3.org/ns/json-ld#context")
44✔
206
    end)
207
    |> Enum.flat_map(fn {_, value} ->
208
      value
209
      |> parse_link_headers()
210
      |> Enum.filter(fn {_, props} -> props["rel"] == "http://www.w3.org/ns/json-ld#context" end)
24✔
211
    end)
212
    |> case do
176✔
213
      [] -> {:ok, nil}
156✔
214
      [{url, _}] -> {:ok, url}
12✔
215
      _ -> {:error, JSON.LD.Error.multiple_context_link_headers()}
8✔
216
    end
217
  end
218

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

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

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

259
      {url, props}
260
    else
261
      _ -> nil
262
    end
263
  end
264

265
  defp parse_json(document) do
266
    case Jason.decode(document) do
588✔
267
      {:ok, _} = ok ->
268
        ok
584✔
269

270
      {:error, %Jason.DecodeError{} = error} ->
4✔
271
        {:error,
272
         JSON.LD.Error.loading_document_failed("JSON parsing failed: #{Exception.message(error)}")}
4✔
273
    end
274
  end
275
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

© 2025 Coveralls, Inc