diff --git a/lib/contentful/entry.ex b/lib/contentful/entry.ex index 832e814..622690d 100644 --- a/lib/contentful/entry.ex +++ b/lib/contentful/entry.ex @@ -7,11 +7,13 @@ defmodule Contentful.Entry do """ alias Contentful.SysData + alias Contentful.Metadata - defstruct [:sys, fields: []] + defstruct [:sys, fields: [], metadata: %Metadata{}] @type t :: %Contentful.Entry{ fields: list(), - sys: SysData.t() + sys: SysData.t(), + metadata: Metadata.t() } end diff --git a/lib/contentful/entry/link_resolver.ex b/lib/contentful/entry/link_resolver.ex index 6d86bd6..1e5c1e3 100644 --- a/lib/contentful/entry/link_resolver.ex +++ b/lib/contentful/entry/link_resolver.ex @@ -19,11 +19,37 @@ defmodule Contentful.Entry.LinkResolver do Inspired by https://github.com/contentful/contentful.js/blob/master/ADVANCED.md#link-resolution """ @spec replace_links_with_entities(Entry.t(), map()) :: Entry.t() - def replace_links_with_entities(%Entry{fields: fields} = entry, %{} = includes) do + def replace_links_with_entities(%Entry{} = entry, %{} = includes) do + replace_links_with_entities(entry, includes, MapSet.new()) + end + + def replace_links_with_entities(entity, _includes), do: entity + + defp replace_links_with_entities(%Entry{sys: %{id: entry_id}} = entry, %{} = includes, visited) do + if MapSet.member?(visited, entry_id) do + # Return the entry without processing links to break the cycle + entry + else + updated_visited = MapSet.put(visited, entry_id) + + updated_fields = + entry.fields + |> Enum.reduce(%{}, fn {name, value}, fields_with_links_resolved -> + new_value = + resolve_links_in_field_with_nesting(value, includes, entry_id, updated_visited) + + Map.put(fields_with_links_resolved, name, new_value) + end) + + struct(entry, fields: updated_fields) + end + end + + defp replace_links_with_entities(%Entry{} = entry, %{} = includes, visited) do updated_fields = - fields + entry.fields |> Enum.reduce(%{}, fn {name, value}, fields_with_links_resolved -> - new_value = resolve_links_in_field_with_nesting(value, includes) + new_value = resolve_links_in_field_with_nesting(value, includes, "unknown", visited) Map.put(fields_with_links_resolved, name, new_value) end) @@ -31,12 +57,23 @@ defmodule Contentful.Entry.LinkResolver do struct(entry, fields: updated_fields) end - def replace_links_with_entities(entity, _includes), do: entity - - defp resolve_links_in_field_with_nesting(field_value, includes) do - case resolved = resolve_links_in_field(field_value, includes) do - %Entry{} -> - replace_links_with_entities(resolved, includes) + defp resolve_links_in_field_with_nesting( + field_value, + includes, + parent_entry_id \\ "unknown", + visited \\ MapSet.new() + ) do + case resolved = resolve_links_in_field(field_value, includes, parent_entry_id, visited) do + %Entry{sys: %{id: linked_entry_id}} = resolved_entry -> + # Check for potential circular reference using the visited set + if MapSet.member?(visited, linked_entry_id) do + resolved_entry + else + replace_links_with_entities(resolved_entry, includes, visited) + end + + %Entry{} = resolved_entry -> + replace_links_with_entities(resolved_entry, includes, visited) _ -> resolved @@ -45,7 +82,9 @@ defmodule Contentful.Entry.LinkResolver do defp resolve_links_in_field( %{"sys" => %{"id" => id, "linkType" => link_type, "type" => "Link"}} = field_value, - %{} = includes + %{} = includes, + parent_entry_id, + _visited ) when map_size(includes) > 0 and not is_nil(id) do Map.get(includes, link_type, []) @@ -57,32 +96,42 @@ defmodule Contentful.Entry.LinkResolver do # matches structs like %Asset{}, which can't be iterated through using the Enum module # and mean links in this field have already been fully resolved anyway - defp resolve_links_in_field(%_{} = field_value, _includes), do: field_value + defp resolve_links_in_field(%_{} = field_value, _includes, _parent_entry_id, _visited), + do: field_value # matches any other map that isn't a struct, maps can be iterated through using Enum # map fields may still have nested links defp resolve_links_in_field( %{} = field_value, - %{} = includes + %{} = includes, + parent_entry_id, + visited ) when map_size(field_value) > 0 and map_size(includes) > 0 do field_value |> Enum.reduce(%{}, fn {nested_field_name, nested_field_value}, field_with_nested_links_resolved -> - updated_nested_field_value = resolve_links_in_field_with_nesting(nested_field_value, includes) + updated_nested_field_value = + resolve_links_in_field_with_nesting( + nested_field_value, + includes, + parent_entry_id, + visited + ) + Map.put(field_with_nested_links_resolved, nested_field_name, updated_nested_field_value) end) end - defp resolve_links_in_field(field_value, %{} = includes) + defp resolve_links_in_field(field_value, %{} = includes, parent_entry_id, visited) when is_list(field_value) and length(field_value) > 0 do field_value |> Enum.map(fn field -> - resolve_links_in_field_with_nesting(field, includes) + resolve_links_in_field_with_nesting(field, includes, parent_entry_id, visited) end) end - defp resolve_links_in_field(field_value, _includes), do: field_value + defp resolve_links_in_field(field_value, _includes, _parent_entry_id, _visited), do: field_value defp resolve_entity(nil, _link_type, fallback), do: fallback diff --git a/lib/contentful/metadata.ex b/lib/contentful/metadata.ex new file mode 100644 index 0000000..870c741 --- /dev/null +++ b/lib/contentful/metadata.ex @@ -0,0 +1,11 @@ +defmodule Contentful.Metadata do + defstruct [ + :concepts, + :tags + ] + + @type t :: %Contentful.Metadata{ + concepts: list(%Contentful.TaxonomyConcept{}), + tags: list(%Contentful.Tag{}) + } +end diff --git a/lib/contentful/query.ex b/lib/contentful/query.ex index 4a12504..d7a23c9 100644 --- a/lib/contentful/query.ex +++ b/lib/contentful/query.ex @@ -156,6 +156,26 @@ defmodule Contentful.Query do queryable end + @doc """ + adds the `links_to_entry` parameter to a query, allowing to filter entries by the entries they link to. + + ## Example + alias Contentful.Delivery.Entries + Entries |> links_to_entry("foobar") |> fetch_all + """ + @spec links_to_entry({Entries, list()}, String.t()) :: {Entries, list()} + def links_to_entry({Entries, parameters}, entry_id) do + {Entries, parameters |> Keyword.put(:links_to_entry, entry_id)} + end + + def links_to_entry(Entries, entry_id) do + links_to_entry({Entries, []}, entry_id) + end + + def links_to_entry(queryable, _entry_id) do + queryable + end + @doc """ will __resolve__ a query chain by eagerly calling the API and resolving the response immediately @@ -194,7 +214,7 @@ defmodule Contentful.Query do url = [ - space |> Delivery.url(env), + space |> Delivery.url(env, []), queryable.endpoint() ] |> Enum.join() @@ -236,11 +256,12 @@ defmodule Contentful.Query do id \\ nil, space \\ Configuration.get(:space_id), env \\ Configuration.get(:environment), - api_key \\ Configuration.get(:access_token) + api_key \\ Configuration.get(:access_token), + opts \\ [] ) - def fetch_one(queryable, id, %Space{sys: %SysData{id: space_id}}, env, api_key) do - fetch_one(queryable, id, space_id, env, api_key) + def fetch_one(queryable, id, %Space{sys: %SysData{id: space_id}}, env, api_key, opts) do + fetch_one(queryable, id, space_id, env, api_key, opts) end def fetch_one( @@ -248,25 +269,28 @@ defmodule Contentful.Query do id, space, env, - api_key + api_key, + opts ) do + endpoint = Keyword.get(opts, :endpoint, Configuration.get(:endpoint)) + url = case {queryable, id} do {Spaces, nil} -> - [space |> Delivery.url()] + [space |> Delivery.url(endpoint: endpoint)] {Spaces, id} -> - [id |> Delivery.url()] + [id |> Delivery.url(endpoint: endpoint)] {_queryable, nil} -> raise ArgumentError, "id is missing!" {{module, _parameters}, id} -> # drops the parameters, as single query responses don't allow parameters - [space |> Delivery.url(env), module.endpoint(), "/#{id}"] + [space |> Delivery.url(env, endpoint: endpoint), module.endpoint(), "/#{id}"] _ -> - [space |> Delivery.url(env), queryable.endpoint(), "/#{id}"] + [space |> Delivery.url(env, endpoint: endpoint), queryable.endpoint(), "/#{id}"] end # since you can pass compose into fetch one, we strip extra params here diff --git a/lib/contentful/request.ex b/lib/contentful/request.ex index d9f5f39..4041e9e 100644 --- a/lib/contentful/request.ex +++ b/lib/contentful/request.ex @@ -51,7 +51,7 @@ defmodule Contentful.Request do |> deconstruct_filters() options - |> Keyword.take([:limit, :skip, :include, :content_type, :query]) + |> Keyword.take([:limit, :skip, :include, :content_type, :query, :links_to_entry]) |> Keyword.merge(filters) end diff --git a/lib/contentful/tag.ex b/lib/contentful/tag.ex new file mode 100644 index 0000000..41fad22 --- /dev/null +++ b/lib/contentful/tag.ex @@ -0,0 +1,7 @@ +defmodule Contentful.Tag do + defstruct [:sys] + + @type t :: %Contentful.Tag{ + sys: %Contentful.SysData{} + } +end diff --git a/lib/contentful/taxonomy_concept.ex b/lib/contentful/taxonomy_concept.ex new file mode 100644 index 0000000..fdc8793 --- /dev/null +++ b/lib/contentful/taxonomy_concept.ex @@ -0,0 +1,41 @@ +defmodule Contentful.TaxonomyConcept do + alias Contentful.TaxonomyConceptScheme + + defstruct [ + :sys, + :uri, + :pref_label, + :alt_labels, + :hidden_labels, + :notations, + :note, + :change_note, + :definition, + :editorial_note, + :history_note, + :scope_note, + :example, + :broader, + :related, + :concept_schemes + ] + + @type t :: %Contentful.TaxonomyConcept{ + sys: %Contentful.SysData{}, + uri: String.t(), + pref_label: map(), + alt_labels: [map()], + hidden_labels: [map()], + notations: list(), + note: map(), + change_note: map(), + definition: map(), + editorial_note: map(), + history_note: map(), + scope_note: map(), + example: map(), + broader: list(__MODULE__.t()), + related: list(__MODULE__.t()), + concept_schemes: list(TaxonomyConceptScheme.t()) + } +end diff --git a/lib/contentful/taxonomy_concept_scheme.ex b/lib/contentful/taxonomy_concept_scheme.ex new file mode 100644 index 0000000..f46b382 --- /dev/null +++ b/lib/contentful/taxonomy_concept_scheme.ex @@ -0,0 +1,23 @@ +defmodule Contentful.TaxonomyConceptScheme do + alias Contentful.TaxonomyConcept + + defstruct [ + :sys, + :uri, + :pref_label, + :definition, + :concepts, + :top_concepts, + :total_concepts + ] + + @type t :: %Contentful.TaxonomyConceptScheme{ + sys: %Contentful.SysData{}, + uri: String.t(), + pref_label: map(), + definition: map(), + concepts: list(TaxonomyConcept.t()), + top_concepts: list(TaxonomyConcept.t()), + total_concepts: integer() + } +end diff --git a/lib/contentful_delivery/delivery.ex b/lib/contentful_delivery/delivery.ex index 3acf413..d8395f7 100644 --- a/lib/contentful_delivery/delivery.ex +++ b/lib/contentful_delivery/delivery.ex @@ -125,6 +125,8 @@ defmodule Contentful.Delivery do import Contentful.Misc, only: [fallback: 2] + require Logger + alias Contentful.Configuration @endpoint "cdn.contentful.com" @@ -144,7 +146,7 @@ defmodule Contentful.Delivery do """ @spec client :: Tesla.Client.t() def client do - case Contentful.http_client do + case Contentful.http_client() do Tesla -> Tesla.client([]) mod -> mod.client() end @@ -167,22 +169,24 @@ defmodule Contentful.Delivery do "https://cdn.contentful.com" = url() """ - @spec url() :: String.t() - def url do - "#{@protocol}://#{host_from_config()}" + @spec url(list()) :: String.t() + def url(opts \\ []) do + endpoint = Keyword.get(opts, :endpoint, Configuration.get(:endpoint)) + + "#{@protocol}://#{host_from_config(endpoint)}" end @doc """ constructs the base url with the space id that got configured in config.exs """ - @spec url(nil) :: String.t() - def url(space) when is_nil(space) do + @spec url(nil, list()) :: String.t() + def url(space, opts) when is_nil(space) do case space_from_config() do nil -> - url() + url(opts) space -> - space |> url + space |> url(opts) end end @@ -192,9 +196,9 @@ defmodule Contentful.Delivery do "https://cdn.contentful.com/spaces/foo" = url("foo") """ - @spec url(String.t()) :: String.t() - def url(space) do - [url(), "spaces", space] |> Enum.join(@separator) + @spec url(String.t(), list()) :: String.t() + def url(space, opts) do + [url(opts), "spaces", space] |> Enum.join(@separator) end @doc """ @@ -210,9 +214,9 @@ defmodule Contentful.Delivery do config :contentful_delivery, environment: "staging" "https://cdn.contentful.com/spaces/foo/environments/staging" = url("foo", nil) """ - @spec url(String.t(), nil) :: String.t() - def url(space, env) when is_nil(env) do - [space |> url(), "environments", environment_from_config()] + @spec url(String.t(), nil, list()) :: String.t() + def url(space, env, opts) when is_nil(env) do + [space |> url(opts), "environments", environment_from_config()] |> Enum.join(@separator) end @@ -223,8 +227,8 @@ defmodule Contentful.Delivery do "https://cdn.contentful.com/spaces/foo/environments/bar" = url("foo", "bar") """ - def url(space, env) do - [space |> url(), "environments", env] |> Enum.join(@separator) + def url(space, env, opts) do + [space |> url(opts), "environments", env] |> Enum.join(@separator) end @doc """ @@ -286,10 +290,15 @@ defmodule Contentful.Delivery do @spec build_error(Tesla.Env.t()) :: {:error, :rate_limit_exceeded, wait_for: integer()} def build_error(%Tesla.Env{ - status: 429, - headers: [{"x-contentful-rate-limit-exceeded", seconds}, _] + status: 429 }) do - {:error, :rate_limit_exceeded, wait_for: seconds} + {:error, :rate_limit_exceeded, wait_for: 3} + end + + def build_error(error_response) do + Logger.error("Error response: #{inspect(error_response)}") + + {:error, :unknown} end @doc """ @@ -308,10 +317,11 @@ defmodule Contentful.Delivery do Configuration.get(:space) end - defp host_from_config do - case Configuration.get(:endpoint) do - nil -> @endpoint + defp host_from_config(endpoint) do + case endpoint do + :delivery -> @endpoint :preview -> @preview_endpoint + nil -> @endpoint value -> value end end diff --git a/lib/contentful_delivery/entries.ex b/lib/contentful_delivery/entries.ex index 7298cba..2a7860b 100644 --- a/lib/contentful_delivery/entries.ex +++ b/lib/contentful_delivery/entries.ex @@ -117,7 +117,8 @@ defmodule Contentful.Delivery.Entries do "createdAt" => created_at, "locale" => locale, "contentType" => %{"sys" => %{"id" => content_type_id}} - } + }, + "metadata" => metadata }) do {:ok, %Entry{ @@ -129,7 +130,8 @@ defmodule Contentful.Delivery.Entries do updated_at: updated_at, created_at: created_at, content_type: %ContentType{id: content_type_id} - } + }, + metadata: metadata }} end end diff --git a/lib/contentful_delivery/taxonomy_concept_schemes.ex b/lib/contentful_delivery/taxonomy_concept_schemes.ex new file mode 100644 index 0000000..794802f --- /dev/null +++ b/lib/contentful_delivery/taxonomy_concept_schemes.ex @@ -0,0 +1,48 @@ +defmodule Contentful.Delivery.TaxonomyConceptSchemes do + @moduledoc """ + TaxonomyConceptSchemes allows for querying the taxonomy concept schemes of a space via `Contentful.Query`. + """ + + alias Contentful.{TaxonomyConceptScheme, Queryable} + + @behaviour Queryable + + @endpoint "/taxonomy/concept-schemes" + + @impl Queryable + def endpoint do + @endpoint + end + + @impl Queryable + def resolve_collection_response(%{"items" => items}) do + schemes = + items + |> Enum.map(&resolve_entity_response/1) + |> Enum.map(fn {:ok, scheme} -> scheme end) + + {:ok, schemes} + end + + @impl Queryable + def resolve_entity_response(%{ + "sys" => sys, + "uri" => uri, + "prefLabel" => pref_label, + "definition" => definition, + "concepts" => concepts, + "topConcepts" => top_concepts, + "totalConcepts" => total_concepts + }) do + {:ok, + %TaxonomyConceptScheme{ + sys: sys, + uri: uri, + pref_label: pref_label, + definition: definition, + concepts: concepts, + top_concepts: top_concepts, + total_concepts: total_concepts + }} + end +end diff --git a/lib/contentful_delivery/taxonomy_concepts.ex b/lib/contentful_delivery/taxonomy_concepts.ex new file mode 100644 index 0000000..facc2ee --- /dev/null +++ b/lib/contentful_delivery/taxonomy_concepts.ex @@ -0,0 +1,66 @@ +defmodule Contentful.Delivery.TaxonomyConcepts do + @moduledoc """ + TaxonomyConcepts allows for querying the taxonomy concepts of a space via `Contentful.Query`. + """ + + alias Contentful.{TaxonomyConcept, Queryable} + + @behaviour Queryable + + @endpoint "/taxonomy/concepts" + + @impl Queryable + def endpoint do + @endpoint + end + + @impl Queryable + def resolve_collection_response(%{"items" => items}) do + concepts = + items + |> Enum.map(&resolve_entity_response/1) + |> Enum.map(fn {:ok, concept} -> concept end) + + {:ok, concepts} + end + + @impl Queryable + def resolve_entity_response(%{ + "sys" => sys, + "uri" => uri, + "prefLabel" => pref_label, + "altLabels" => alt_labels, + "hiddenLabels" => hidden_labels, + "notations" => notations, + "note" => note, + "changeNote" => change_note, + "definition" => definition, + "editorialNote" => editorial_note, + "historyNote" => history_note, + "scopeNote" => scope_note, + "example" => example, + "broader" => broader, + "related" => related, + "conceptSchemes" => concept_schemes + }) do + {:ok, + %TaxonomyConcept{ + sys: sys, + uri: uri, + pref_label: pref_label, + alt_labels: alt_labels, + hidden_labels: hidden_labels, + notations: notations, + note: note, + change_note: change_note, + definition: definition, + editorial_note: editorial_note, + history_note: history_note, + scope_note: scope_note, + example: example, + broader: broader, + related: related, + concept_schemes: concept_schemes + }} + end +end