diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c55cde..055e4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,44 +2,50 @@ ## Unreleased +## 0.6.0 + +- [#181](https://github.com/contentful-userland/contentful.ex/pull/181) Resolve all link fields found within "items" in an API response, and replace the links with actual entities, if available from the "includes" section of API response. Inspired by https://github.com/contentful/contentful.js/blob/master/ADVANCED.md#link-resolution + +Note: BREAKING CHANGE, 'assets' field on Entry struct is no longer populated. All Assets for an Entry are now embedded directly within 'fields' instead + ## 0.5.0 -* [#97](https://github.com/contentful-userland/contentful.ex/pull/97) switches underlying http adapter to `tesla` for higher flexibility. Thank you @OldhamMade -* tests against Elixir 1.13 and updates required elixir version to 1.11 +- [#97](https://github.com/contentful-userland/contentful.ex/pull/97) switches underlying http adapter to `tesla` for higher flexibility. Thank you @OldhamMade +- tests against Elixir 1.13 and updates required elixir version to 1.11 ## 0.4.1 -* [#75](https://github.com/contentful-labs/contentful.ex/issue/75) fixes an issue concerning malformed docs, thanks @OldhamMade +- [#75](https://github.com/contentful-labs/contentful.ex/issue/75) fixes an issue concerning malformed docs, thanks @OldhamMade ## 0.4.0 -* [#49](https://github.com/contentful-labs/contentful.ex/pull/49) Adds extended query syntax for building more complex queries, as suggested by @ryansch in [#38](https://github.com/contentful-labs/contentful.ex/issues/38) -* adds testing against Elixir 1.10.4 +- [#49](https://github.com/contentful-labs/contentful.ex/pull/49) Adds extended query syntax for building more complex queries, as suggested by @ryansch in [#38](https://github.com/contentful-labs/contentful.ex/issues/38) +- adds testing against Elixir 1.10.4 ## 0.3.2 -* [#47](https://github.com/contentful-labs/contentful.ex/pull/47) Handle bitstring case when resolving assets (thanks @aspala) +- [#47](https://github.com/contentful-labs/contentful.ex/pull/47) Handle bitstring case when resolving assets (thanks @aspala) ## 0.3.1 -* [#37](https://github.com/contentful-labs/contentful.ex/issues/37) Fixed an error preventing correct entity resolution for assets (thanks @OldhamMade) -* [#44](https://github.com/contentful-labs/contentful.ex/issues/44) Adds missing common properties to content types, assets entries (thanks @OldhamMade) -* [#36](https://github.com/contentful-labs/contentful.ex/issues/36) Added dependabot for keeping dependencies up to date -* [#9](https://github.com/contentful-labs/contentful.ex/issues/9) Added the ability to specify an endpoint other than the Delivery API -* Improved some README sections about how to query certain entities +- [#37](https://github.com/contentful-labs/contentful.ex/issues/37) Fixed an error preventing correct entity resolution for assets (thanks @OldhamMade) +- [#44](https://github.com/contentful-labs/contentful.ex/issues/44) Adds missing common properties to content types, assets entries (thanks @OldhamMade) +- [#36](https://github.com/contentful-labs/contentful.ex/issues/36) Added dependabot for keeping dependencies up to date +- [#9](https://github.com/contentful-labs/contentful.ex/issues/9) Added the ability to specify an endpoint other than the Delivery API +- Improved some README sections about how to query certain entities ## 0.3.0 ### Features -* Introduced a DSL that can be composed into queries -* Solved the `include` removal by readding it (#20) - * Also allows for resolving assets included in entries +- Introduced a DSL that can be composed into queries +- Solved the `include` removal by readding it (#20) + - Also allows for resolving assets included in entries ### Chores -* Deleted most Context(s) module code -* Updated the docs with more working examples +- Deleted most Context(s) module code +- Updated the docs with more working examples ## 0.2.0 @@ -47,38 +53,38 @@ Note: This release is incompatible with previous releases as this lacks the `inc ### Features -* Reworked the way data can be queried from the CDA endpoint -* Split up the modules into mapping to different Contentful APIs -* Introduced a way to stream the CDA API endpoints instead of relying on pagination -* Added +- Reworked the way data can be queried from the CDA endpoint +- Split up the modules into mapping to different Contentful APIs +- Introduced a way to stream the CDA API endpoints instead of relying on pagination +- Added ### Chores -* Added badges and `ex_doc` integration (published via [hex.pm](https://hex.pm)) -* Updated the docs with examples +- Added badges and `ex_doc` integration (published via [hex.pm](https://hex.pm)) +- Updated the docs with examples ## 0.1.1 ### Fixed -* Fixed issue on empty includes -* Fixed compatibility with Elixir 1.4 +- Fixed issue on empty includes +- Fixed compatibility with Elixir 1.4 ## 0.1.0 ### Added -* Added some spec coverage for all endpoints and include resolution -* Added Linter tool +- Added some spec coverage for all endpoints and include resolution +- Added Linter tool ### Changed -* Improved syntax conventions -* Refactored Include Resolution into it's own module +- Improved syntax conventions +- Refactored Include Resolution into it's own module ## 0.0.1 [INITIAL RELEASE] ### Added -* Added all CDA Endpoints -* Added Basic Include Resolution +- Added all CDA Endpoints +- Added Basic Include Resolution diff --git a/README.md b/README.md index 8dd4def..c07ac92 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,13 @@ then open [`http://localhost:8000`](http://localhost:8000). ## Contributing & Development -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request +1. Fork the repository +2. Create your feature branch on your fork (`git checkout -b my-new-feature`) +3. Make your changes +4. Run tests (`mix test`) and static analysis (`mix credo --strict`) +5. Commit your changes (`git commit -am 'Add some feature'`) +6. Push to your branch (`git push origin my-new-feature`) +7. Create a Pull Request on this original repository ### Notes on setup diff --git a/fixture/vcr_cassettes/some_entries_have_links.json b/fixture/vcr_cassettes/some_entries_have_links.json new file mode 100644 index 0000000..b0df81c --- /dev/null +++ b/fixture/vcr_cassettes/some_entries_have_links.json @@ -0,0 +1,57 @@ +[ + { + "request": { + "body": "", + "headers": { + "authorization": "***", + "User-Agent": "Contentful Elixir SDK", + "X-Contentful-User-Agent": "Contentful Elixir SDK", + "accept": "application/json" + }, + "method": "get", + "options": { + "httpc_options": [], + "http_options": { + "autoredirect": "false" + } + }, + "request_body": "", + "url": "https://cdn.contentful.com/spaces/bmehzfuz4raf/environments/master/entries?content_type=blogPost&sys.id=2PtC9h1YqIA6kaUaIsWEQ0" + }, + "response": { + "binary": false, + "body": "{\"sys\":{\"type\":\"Array\"},\"total\":1,\"skip\":0,\"limit\":100,\"items\":[{\"metadata\":{\"tags\":[]},\"sys\":{\"space\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Space\",\"id\":\"bmehzfuz4raf\"}},\"id\":\"2PtC9h1YqIA6kaUaIsWEQ0\",\"type\":\"Entry\",\"createdAt\":\"2019-03-22T08:33:45.069Z\",\"updatedAt\":\"2020-04-18T18:44:10.843Z\",\"environment\":{\"sys\":{\"id\":\"master\",\"type\":\"Link\",\"linkType\":\"Environment\"}},\"revision\":2,\"contentType\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"ContentType\",\"id\":\"blogPost\"}},\"locale\":\"en-US\"},\"fields\":{\"title\":\"Static sites are great\",\"slug\":\"static-sites-are-great\",\"heroImage\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Asset\",\"id\":\"4NzwDSDlGECGIiokKomsyI\"}},\"description\":\"Worry less about security, caching, and talking to the server. Static sites are the new thing.\",\"body\":\"## The case for the static site generator\\n\\nMore and more developers are jumping on the \\\"go static train\\\", and rightfully so. Static pages are fast, lightweight, they scale well. They are more secure, and simple to maintain and they allow you to focus all your time and effort on the user interface. Often times, this dedication really shows.\\n\\nIt just so happens that static site generators are mostly loved by developers, but not by the average Joe. They do not offer WYSIWYG, previewing on demo sites may take an update cycle, they are often based on markdown text files, and they require some knowledge of modern day repositories.\\n\\nMoreover, when teams are collaborating, it can get complicated quickly. Has this article already been proof-read or reviewed? Is this input valid? Are user permissions available, e.g. for administering adding and removing team members? Can this article be published at a future date? How can a large repository of content be categorized, organized, and searched? All these requirements have previously been more or less solved within the admin area of your CMS. But of course with all the baggage that made you leave the appserver-app-database-in-one-big-blob stack in the first place.\\n\\n## Content APIs to the rescue\\n\\nAn alternative is decoupling the content management aspect from the system. And then replacing the maintenance prone server with a cloud based web service offering. Effectively, instead of your CMS of old, you move to a [Content Management as a Service (CMaaS)](https://www.contentful.com/r/knowledgebase/content-as-a-service/ \\\"Content Management as a Service (CMaaS)\\\") world, with a content API to deliver all your content. That way, you get the all the [benefits of content management features](http://www.digett.com/blog/01/16/2014/pairing-static-websites-cms \\\"benefits of content management features\\\") while still being able to embrace the static site generator mantra.\\n\\nIt so happens that Contentful is offering just that kind of content API. A service that\\n\\n* from the ground up has been designed to be fast, scalable, secure, and offer high uptime, so that you don’t have to worry about maintenance ever again.\\n* offers a powerful editor and lots of flexibility in creating templates for your documents that your editors can reuse and combine, so that no developers resources are required in everyday writing and updating tasks.\\n* separates content from presentation, so you can reuse your content repository for any device platform your heart desires. That way, you can COPE (\\\"create once, publish everywhere\\\").\\n* offers webhooks that you can use to rebuild your static site in a fully automated fashion every time your content is modified.\\n\\nExtracted from the article [CMS-functionality for static site generators](https://www.contentful.com/r/knowledgebase/contentful-api-cms-static-site-generators/ \\\"CMS-functionality for static site generators\\\"). Read more about the [static site generators supported by Contentful](https://www.contentful.com/developers/docs/tools/staticsitegenerators/ \\\"static site generators supported by Contentful\\\").\",\"author\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Entry\",\"id\":\"15jwOBqpxqSAOy2eOO4S0m\"}},\"publishDate\":\"2017-05-16T00:00+02:00\",\"tags\":[\"javascript\",\"static-sites\"]}}],\"includes\":{\"Entry\":[{\"metadata\":{\"tags\":[]},\"sys\":{\"space\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Space\",\"id\":\"bmehzfuz4raf\"}},\"id\":\"15jwOBqpxqSAOy2eOO4S0m\",\"type\":\"Entry\",\"createdAt\":\"2019-03-22T08:33:44.329Z\",\"updatedAt\":\"2020-04-18T18:44:10.435Z\",\"environment\":{\"sys\":{\"id\":\"master\",\"type\":\"Link\",\"linkType\":\"Environment\"}},\"revision\":2,\"contentType\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"ContentType\",\"id\":\"person\"}},\"locale\":\"en-US\"},\"fields\":{\"name\":\"John Doe\",\"title\":\"Web Developer\",\"company\":\"ACME\",\"shortBio\":\"Research and recommendations for modern stack websites.\",\"email\":\"john@doe.com\",\"phone\":\"0176 / 1234567\",\"facebook\":\"johndoe\",\"twitter\":\"johndoe\",\"github\":\"johndoe\",\"image\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Asset\",\"id\":\"7orLdboQQowIUs22KAW4U\"}}}}],\"Asset\":[{\"metadata\":{\"tags\":[]},\"sys\":{\"space\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Space\",\"id\":\"bmehzfuz4raf\"}},\"id\":\"4NzwDSDlGECGIiokKomsyI\",\"type\":\"Asset\",\"createdAt\":\"2019-03-22T08:33:39.477Z\",\"updatedAt\":\"2020-04-18T18:44:05.885Z\",\"environment\":{\"sys\":{\"id\":\"master\",\"type\":\"Link\",\"linkType\":\"Environment\"}},\"revision\":2,\"locale\":\"en-US\"},\"fields\":{\"title\":\"City\",\"description\":\"City pictured from the sky\",\"file\":{\"url\":\"//images.ctfassets.net/bmehzfuz4raf/4NzwDSDlGECGIiokKomsyI/90b101c671a5969764757aaefb0c3466/denys-nevozhai-100695.jpg\",\"details\":{\"size\":15736986,\"image\":{\"width\":3992,\"height\":2992}},\"fileName\":\"denys-nevozhai-100695.jpg\",\"contentType\":\"image/jpeg\"}}},{\"metadata\":{\"tags\":[]},\"sys\":{\"space\":{\"sys\":{\"type\":\"Link\",\"linkType\":\"Space\",\"id\":\"bmehzfuz4raf\"}},\"id\":\"7orLdboQQowIUs22KAW4U\",\"type\":\"Asset\",\"createdAt\":\"2019-03-22T08:33:38.110Z\",\"updatedAt\":\"2020-04-18T18:44:04.820Z\",\"environment\":{\"sys\":{\"id\":\"master\",\"type\":\"Link\",\"linkType\":\"Environment\"}},\"revision\":2,\"locale\":\"en-US\"},\"fields\":{\"title\":\"Sparkler\",\"description\":\"John with Sparkler\",\"file\":{\"url\":\"//images.ctfassets.net/bmehzfuz4raf/7orLdboQQowIUs22KAW4U/ae1e04accdfcf6c3def7a449d12bff4c/matt-palmer-254999.jpg\",\"details\":{\"size\":2293094,\"image\":{\"width\":3000,\"height\":2000}},\"fileName\":\"matt-palmer-254999.jpg\",\"contentType\":\"image/jpeg\"}}}]}}\n", + "headers": { + "connection": "keep-alive", + "date": "Tue, 30 Apr 2024 08:46:13 GMT", + "via": "1.1 varnish, 1.1 varnish", + "accept-ranges": "bytes", + "age": "207", + "etag": "\"6650198899795356495\"", + "server": "Contentful", + "content-length": "6149", + "content-type": "application/vnd.contentful.delivery.v1+json", + "cf-space-id": "bmehzfuz4raf", + "cf-environment-id": "master", + "cf-environment-uuid": "fe6aa454-82d9-4c41-83f9-5f7194ff7797", + "cf-organization-id": "42rCduzn2g6MhnFOjK2VMh", + "x-contentful-route": "/spaces/:space/environments/:environment/entries", + "x-content-type-options": "nosniff", + "contentful-api": "cda", + "x-contentful-region": "us-east-1", + "access-control-allow-origin": "*", + "access-control-allow-headers": "Accept,Accept-Language,Authorization,Cache-Control,Content-Length,Content-Range,Content-Type,DNT,Destination,Expires,If-Match,If-Modified-Since,If-None-Match,Keep-Alive,Last-Modified,Origin,Pragma,Range,User-Agent,X-Http-Method-Override,X-Mx-ReqToken,X-Requested-With,X-Contentful-Version,X-Contentful-Content-Type,X-Contentful-Organization,X-Contentful-Skip-Transformation,X-Contentful-User-Agent,X-Contentful-Enable-Alpha-Feature,X-Contentful-Resource-Resolution", + "access-control-expose-headers": "Etag", + "access-control-max-age": "86400", + "access-control-allow-methods": "GET,HEAD,OPTIONS", + "x-served-by": "cache-ewr18121-EWR, cache-lcy-eglc8600094-LCY", + "x-cache-hits": "0, 0", + "x-timer": "S1714466774.847754,VS0,VE1", + "x-cache": "HIT", + "x-contentful-request-id": "c0a1e440-26e0-468c-a225-cfb8c6c5230a" + }, + "status_code": ["HTTP/1.1", 200, "OK"], + "type": "ok" + } + } +] diff --git a/lib/contentful/entry.ex b/lib/contentful/entry.ex index 1ad4f58..832e814 100644 --- a/lib/contentful/entry.ex +++ b/lib/contentful/entry.ex @@ -6,13 +6,12 @@ defmodule Contentful.Entry do See the [official documentation for more information](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/entries). """ - alias Contentful.{Asset, SysData} + alias Contentful.SysData - defstruct [:sys, fields: [], assets: []] + defstruct [:sys, fields: []] @type t :: %Contentful.Entry{ fields: list(), - sys: SysData.t(), - assets: list(Asset.t()) + sys: SysData.t() } end diff --git a/lib/contentful/entry/asset_resolver.ex b/lib/contentful/entry/asset_resolver.ex deleted file mode 100644 index 2c75464..0000000 --- a/lib/contentful/entry/asset_resolver.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule Contentful.Entry.AssetResolver do - @moduledoc """ - The module provides functions to resolve the Entry <-> Asset relationships. - """ - - alias Contentful.Entry - - @doc """ - extracts asset ids nested in the fields of a single entry, essentially collecting asset_ids - from the tree structure of the entry fields accomodating for serval field types. - """ - @spec find_linked_asset_ids(Entry.t()) :: list(String.t()) - def find_linked_asset_ids(%Entry{fields: fields}) do - fields |> Enum.reduce([], &find_in_data/2) |> Enum.uniq() - end - - defp find_in_data( - {_field_name, %{"sys" => %{"id" => id, "linkType" => "Asset", "type" => "Link"}}}, - acc - ) do - [id | acc] - end - - defp find_in_data( - {_field_name, map}, - acc - ) - when is_map(map) do - map |> Enum.reduce(acc, &find_in_data/2) - end - - defp find_in_data({_field_name, []}, acc) do - acc - end - - # match taglists which have the form ["Hello", "world"] - defp find_in_data( - {_field_name, [head | _tail]}, - acc - ) - when is_bitstring(head) do - acc - end - - defp find_in_data( - {_field_name, list}, - acc - ) - when is_list(list) do - list |> Enum.flat_map(fn fields -> fields |> Enum.reduce(acc, &find_in_data/2) end) - end - - defp find_in_data({_field_name, _value}, acc), do: acc -end diff --git a/lib/contentful/entry/link_resolver.ex b/lib/contentful/entry/link_resolver.ex new file mode 100644 index 0000000..6d86bd6 --- /dev/null +++ b/lib/contentful/entry/link_resolver.ex @@ -0,0 +1,102 @@ +defmodule Contentful.Entry.LinkResolver do + @moduledoc """ + The module provides functions to resolve Links included in items returned in an API response. + """ + + alias Contentful.Delivery.ContentTypes + alias Contentful.Delivery.{Assets, ContentTypes, Entries, Locales, Spaces} + alias Contentful.Entry + + @doc """ + In Contentful, you can create content that references other content. These are called "links". + In API responses, any "links" in the "items" returned have a type "Link" and specify the "id" of the link but do not include + all it's fields directly in the "items" array. Instead the full "link" and it's fields may be provided in the "includes" section of the response, + depending on the value of the "include" query parameter in the request URL. + + This function will find any "links" in the "fields" of an Entry, and replace them with the corresponding entities from the "includes" section + This makes parsing the response easier, as you don't need to manually extract every linked entry from the "includes" section of the response. + + 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 + updated_fields = + fields + |> Enum.reduce(%{}, fn {name, value}, fields_with_links_resolved -> + new_value = resolve_links_in_field_with_nesting(value, includes) + + Map.put(fields_with_links_resolved, name, new_value) + end) + + 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) + + _ -> + resolved + end + end + + defp resolve_links_in_field( + %{"sys" => %{"id" => id, "linkType" => link_type, "type" => "Link"}} = field_value, + %{} = includes + ) + when map_size(includes) > 0 and not is_nil(id) do + Map.get(includes, link_type, []) + |> Enum.find(fn %{"sys" => %{"id" => link_id}} -> + link_id == id + end) + |> resolve_entity(link_type, field_value) + end + + # 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 + + # 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 + ) + 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) + Map.put(field_with_nested_links_resolved, nested_field_name, updated_nested_field_value) + end) + end + + defp resolve_links_in_field(field_value, %{} = includes) + 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) + end) + end + + defp resolve_links_in_field(field_value, _includes), do: field_value + + defp resolve_entity(nil, _link_type, fallback), do: fallback + + defp resolve_entity(entity, link_type, fallback) do + {:ok, resolved} = + case link_type do + "Asset" -> Assets.resolve_entity_response(entity) + "Entry" -> Entries.resolve_entity_response(entity) + "ContentType" -> ContentTypes.resolve_entity_response(entity) + "Locale" -> Locales.resolve_entity_response(entity) + "Space" -> Spaces.resolve_entity_response(entity) + _ -> {:ok, fallback} + end + + resolved + end +end diff --git a/lib/contentful/sys_data.ex b/lib/contentful/sys_data.ex index 01cadf5..92214f9 100644 --- a/lib/contentful/sys_data.ex +++ b/lib/contentful/sys_data.ex @@ -3,21 +3,27 @@ defmodule Contentful.SysData do The SysData represents internal additional data for Contentful API objects, usually found in the "sys" part of the response objects. It's also referred to as "common properties". - See the [official documentation for more information](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/locales). + See the [official documentation for more information](https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/common-resource-attributes). """ - defstruct [:id, :revision, :version, :created_at, :updated_at, locale: nil, content_type: nil] + defstruct [ + :id, + :revision, + :created_at, + :updated_at, + locale: nil, + content_type: nil + ] @type t :: %Contentful.SysData{ id: String.t(), - # NOTE Entries, assets, content types + # NOTE: revision only exists in Entry, Asset, ContentType revision: integer() | nil, - version: integer() | nil, - # NOTE: timestamps exist for Asset, Entry and ContentType + # NOTE: timestamps only exists in Asset, Entry and ContentType created_at: String.t() | nil, updated_at: String.t() | nil, - # NOTE: locale string only exists in entries and assets + # NOTE: locale string only exists in Entry and Asset locale: String | nil, - # NOTE: ContentType only exists for entries + # NOTE: ContentType only exists in Entry content_type: String.t() | nil } end diff --git a/lib/contentful_delivery/entries.ex b/lib/contentful_delivery/entries.ex index 2f40818..7298cba 100644 --- a/lib/contentful_delivery/entries.ex +++ b/lib/contentful_delivery/entries.ex @@ -26,23 +26,19 @@ defmodule Contentful.Delivery.Entries do {:ok, entries, total: _total_count_of_entries} = Entries |> fetch_all(space_id, environment, access_token) - ## More advanced query with included assets + ## More advanced query with included links - Entries can have assets included, which limits the amount of times a client has to request data from the server: + Entries can have links included, which limits the amount of times a client has to request data from the server: import Contentful.Query alias Contentful.{Asset, Entry} alias Contentful.Delivery.Entries # The default include depth is 1 (max 10) - {:ok, [ %Entry{assets: assets} = entry | _ ], total: _total_count_of_entries} = + {:ok, [ %Entry{} = entry | _ ], total: _total_count_of_entries} = Entries |> include |> fetch_all - assets |> Enum.map(fn %Asset{fields: fields} -> {fields.title, fields.file} end) - - # you can also just get the assets belonging to an entry lazily: - - Entries |> include |> stream |> Stream.flat_map(fn entry -> entry.assets end) |> Enum.take(2) + # any links within entry.fields will have been replaced with actual entities (e.g. an %Asset{] or %Entry{} struct) ## Accessing common resource attributes @@ -62,9 +58,8 @@ defmodule Contentful.Delivery.Entries do """ - alias Contentful.{Asset, ContentType, Entry, Queryable, SysData} - alias Contentful.Delivery.Assets - alias Contentful.Entry.AssetResolver + alias Contentful.{ContentType, Entry, Queryable, SysData} + alias Contentful.Entry.LinkResolver @behaviour Queryable @@ -76,17 +71,21 @@ defmodule Contentful.Delivery.Entries do end @doc """ - specifies the collection resolver for the case when assets are included within the entries response + specifies the collection resolver for the case when links are included within the entries response """ def resolve_collection_response(%{ "total" => total, "items" => items, - "includes" => %{"Asset" => assets} - }) do + "includes" => includes + }) + when includes != %{} do {:ok, entries, total: total} = resolve_collection_response(%{"total" => total, "items" => items}) - {:ok, entries |> Enum.map(fn entry -> entry |> resolve_assets(assets) end), total: total} + {:ok, + entries + |> Enum.map(fn entry -> entry |> LinkResolver.replace_links_with_entities(includes) end), + total: total} end @doc """ @@ -133,17 +132,4 @@ defmodule Contentful.Delivery.Entries do } }} end - - @spec resolve_assets(Entry.t(), list(Asset.t())) :: Entry.t() - defp resolve_assets(%Entry{} = entry, assets) do - asset_ids = entry |> AssetResolver.find_linked_asset_ids() - - assets_for_entry = - assets - |> Enum.map(&Assets.resolve_entity_response/1) - |> Enum.map(fn {:ok, asset} -> asset end) - |> Enum.filter(fn %Asset{sys: %SysData{id: id}} -> asset_ids |> Enum.member?(id) end) - - entry |> Map.put(:assets, assets_for_entry) - end end diff --git a/mix.exs b/mix.exs index 574c399..00795b2 100644 --- a/mix.exs +++ b/mix.exs @@ -15,7 +15,7 @@ defmodule Contentful.Mixfile do alias Contentful.Delivery.{Assets, ContentTypes, Entries, Locales, Spaces} - @version "0.5.0" + @version "0.6.0" def project do [ diff --git a/test/contentful/entry/asset_resolver_test.exs b/test/contentful/entry/asset_resolver_test.exs deleted file mode 100644 index 56d878b..0000000 --- a/test/contentful/entry/asset_resolver_test.exs +++ /dev/null @@ -1,132 +0,0 @@ -defmodule Contentful.Entry.AssetResolverTest do - use ExUnit.Case - - alias Contentful.{Entry, SysData} - alias Contentful.Entry.AssetResolver - - describe "find_linked_asset_ids/1" do - test "resolves simple ids from fields" do - entry = %Entry{ - assets: [], - fields: %{ - "image" => %{ - "sys" => %{ - "id" => "5ECf6ltDUOnX441PtBR8Wk", - "linkType" => "Asset", - "type" => "Link" - } - }, - "name" => "A standard category" - }, - sys: %SysData{ - id: "4RPjazUzQMqemyNlcD3b9i", - revision: 2, - version: nil - } - } - - ["5ECf6ltDUOnX441PtBR8Wk"] = entry |> AssetResolver.find_linked_asset_ids() - end - - test "resolves ids nested in complex fields" do - entry = %Entry{ - assets: [], - fields: %{ - "description" => %{ - "content" => [ - %{ - "content" => [ - %{ - "data" => %{}, - "marks" => [], - "nodeType" => "text", - "value" => "as seen in Zoolander." - } - ], - "data" => %{}, - "nodeType" => "paragraph" - }, - %{ - "content" => [ - %{ - "data" => %{}, - "marks" => [], - "nodeType" => "text", - "value" => "Also:" - } - ], - "data" => %{}, - "nodeType" => "paragraph" - }, - %{ - "content" => [], - "data" => %{ - "target" => %{ - "sys" => %{ - "id" => "5UeyMKZrmqMYyMMJvCP3Ls", - "linkType" => "Entry", - "type" => "Link" - } - } - }, - "nodeType" => "embedded-entry-block" - }, - %{ - "content" => [], - "data" => %{ - "target" => %{ - "sys" => %{ - "id" => "577fpmbIfYD71VCjCpYA84", - "linkType" => "Asset", - "type" => "Link" - } - } - }, - "nodeType" => "embedded-asset-block" - }, - %{ - "content" => [ - %{"data" => %{}, "marks" => [], "nodeType" => "text", "value" => ""} - ], - "data" => %{}, - "nodeType" => "paragraph" - } - ], - "data" => %{}, - "nodeType" => "document" - }, - "image" => %{ - "sys" => %{ - "id" => "5ECf6ltDUOnX441PtBR8Wk", - "linkType" => "Asset", - "type" => "Link" - } - }, - "name" => "Blue steel", - "price" => 12, - "sku" => 1234, - "stock" => 12 - }, - sys: %SysData{ - id: "5UeyMKZrmqMYyMMJvCP3Ls", - revision: 6, - version: nil - } - } - - ["5ECf6ltDUOnX441PtBR8Wk", "577fpmbIfYD71VCjCpYA84"] = - entry |> AssetResolver.find_linked_asset_ids() - end - - test "does not choke up on contentful taglist (list of bitstrings)" do - entry = %Entry{ - assets: [], - fields: %{ - "my-tags" => ["hello", "world"] - } - } - - [] = entry |> AssetResolver.find_linked_asset_ids() - end - end -end diff --git a/test/contentful/entry/link_resolver_test.exs b/test/contentful/entry/link_resolver_test.exs new file mode 100644 index 0000000..08abc49 --- /dev/null +++ b/test/contentful/entry/link_resolver_test.exs @@ -0,0 +1,597 @@ +defmodule Contentful.Entry.LinkResolverTest do + use ExUnit.Case + + alias Contentful.Asset + alias Contentful.{ContentType, Entry, SysData} + alias Contentful.Entry.LinkResolver + + describe "replace_links_with_entities/2" do + test "Entry with no links returns unchanged Entry" do + includes = %{ + "Entry" => [ + %{ + "fields" => %{ + "company" => "ACME", + "email" => "john@doe.com", + "facebook" => "johndoe" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "person", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "type" => "Entry", + "updatedAt" => "2020-04-18T18:44:10.435Z" + } + } + ] + } + + %Entry{} = %Entry{} |> LinkResolver.replace_links_with_entities(includes) + end + + test "empty includes returns unchanged Entry" do + entry = %Entry{ + fields: %{ + "author" => %{ + "sys" => %{ + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "linkType" => "Entry", + "type" => "Link" + } + } + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "blogPost"} + } + } + + ^entry = entry |> LinkResolver.replace_links_with_entities(%{}) + end + + test "links found in 'includes' are resolved in entry, others not found are left unchanged" do + entry = %Entry{ + fields: %{ + "author" => %{ + "sys" => %{ + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "linkType" => "Entry", + "type" => "Link" + } + }, + "heroImage" => %{ + "sys" => %{ + "id" => "4NzwDSDlGECGIiokKomsyI", + "linkType" => "Asset", + "type" => "Link" + } + } + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "blogPost"} + } + } + + includes = %{ + "Entry" => [ + %{ + "fields" => %{ + "company" => "ACME", + "email" => "john@doe.com", + "name" => "John Doe" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "person", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "type" => "Entry", + "revision" => 2, + "createdAt" => "2019-03-22T08:33:44.329Z", + "updatedAt" => "2020-04-18T18:44:10.435Z", + "locale" => "en-US" + } + } + ] + } + + %Entry{ + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + created_at: "2019-03-22T08:33:45.069Z", + updated_at: "2020-04-18T18:44:10.843Z", + locale: "en-US", + content_type: %ContentType{ + id: "blogPost" + } + }, + fields: %{ + "author" => %Entry{ + sys: %SysData{ + id: "15jwOBqpxqSAOy2eOO4S0m", + revision: 2, + created_at: "2019-03-22T08:33:44.329Z", + updated_at: "2020-04-18T18:44:10.435Z", + locale: "en-US", + content_type: %ContentType{ + id: "person" + } + }, + fields: %{"company" => "ACME", "email" => "john@doe.com", "name" => "John Doe"} + }, + "heroImage" => %{ + "sys" => %{ + "id" => "4NzwDSDlGECGIiokKomsyI", + "linkType" => "Asset", + "type" => "Link" + } + } + } + } = entry |> LinkResolver.replace_links_with_entities(includes) + end + + test "resolves links nested in complex fields" do + entry = %Entry{ + fields: %{ + "author" => %{ + "sys" => %{ + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "linkType" => "Entry", + "type" => "Link" + } + }, + "description" => %{ + "content" => [ + %{ + "content" => [ + %{ + "data" => %{}, + "marks" => [], + "nodeType" => "text", + "value" => "as seen in Zoolander." + } + ], + "data" => %{}, + "nodeType" => "paragraph" + }, + %{ + "content" => [], + "data" => %{ + "target" => %{ + "sys" => %{ + "id" => "7orLdboQQowIUs22KAW4U", + "linkType" => "Asset", + "type" => "Link" + } + } + }, + "nodeType" => "embedded-asset-block" + }, + %{ + "content" => [ + %{"data" => %{}, "marks" => [], "nodeType" => "text", "value" => ""} + ], + "data" => %{}, + "nodeType" => "paragraph" + } + ], + "data" => %{}, + "nodeType" => "document" + } + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "blogPost"} + } + } + + includes = %{ + "Entry" => [ + %{ + "fields" => %{ + "company" => "ACME", + "email" => "john@doe.com", + "name" => "John Doe", + "image" => %{ + "sys" => %{ + "type" => "Link", + "linkType" => "Asset", + "id" => "7orLdboQQowIUs22KAW4U" + } + } + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "person", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "type" => "Entry", + "revision" => 2, + "createdAt" => "2019-03-22T08:33:44.329Z", + "updatedAt" => "2020-04-18T18:44:10.435Z", + "locale" => "en-US" + } + } + ], + "Asset" => [ + %{ + "metadata" => %{ + "tags" => [] + }, + "sys" => %{ + "space" => %{ + "sys" => %{ + "type" => "Link", + "linkType" => "Space", + "id" => "gtrsnz13drim" + } + }, + "id" => "7orLdboQQowIUs22KAW4U", + "type" => "Asset", + "createdAt" => "2019-03-22T08:33:38.110Z", + "updatedAt" => "2020-04-18T18:44:04.820Z", + "environment" => %{ + "sys" => %{ + "id" => "master", + "type" => "Link", + "linkType" => "Environment" + } + }, + "revision" => 2, + "locale" => "en-US" + }, + "fields" => %{ + "title" => "Sparkler", + "description" => "John with Sparkler", + "file" => %{ + "url" => + "//images.ctfassets.net/gtrsnz13drim/7orLdboQQowIUs22KAW4U/ae1e04accdfcf6c3def7a449d12bff4c/matt-palmer-254999.jpg", + "details" => %{ + "size" => 2_293_094, + "image" => %{ + "width" => 3000, + "height" => 2000 + } + }, + "fileName" => "matt-palmer-254999.jpg", + "contentType" => "image/jpeg" + } + } + } + ] + } + + %Entry{ + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + created_at: "2019-03-22T08:33:45.069Z", + updated_at: "2020-04-18T18:44:10.843Z", + locale: "en-US", + content_type: %ContentType{ + id: "blogPost" + } + }, + fields: %{ + "author" => %Entry{ + sys: %SysData{ + id: "15jwOBqpxqSAOy2eOO4S0m", + revision: 2, + created_at: "2019-03-22T08:33:44.329Z", + updated_at: "2020-04-18T18:44:10.435Z", + locale: "en-US", + content_type: %ContentType{ + id: "person" + } + }, + fields: %{ + "company" => "ACME", + "email" => "john@doe.com", + "name" => "John Doe", + "image" => %Asset{ + sys: %SysData{ + id: "7orLdboQQowIUs22KAW4U" + }, + fields: %Asset.Fields{ + title: "Sparkler", + description: "John with Sparkler", + file: %{ + content_type: "image/jpeg", + details: %{ + "image" => %{ + "height" => 2000, + "width" => 3000 + }, + "size" => 2_293_094 + }, + file_name: "matt-palmer-254999.jpg", + url: %URI{ + host: "images.ctfassets.net", + path: + "/gtrsnz13drim/7orLdboQQowIUs22KAW4U/ae1e04accdfcf6c3def7a449d12bff4c/matt-palmer-254999.jpg" + } + } + } + } + } + }, + "description" => %{ + "content" => [ + %{ + "content" => [ + %{ + "data" => %{}, + "marks" => [], + "nodeType" => "text", + "value" => "as seen in Zoolander." + } + ], + "data" => %{}, + "nodeType" => "paragraph" + }, + %{ + "content" => [], + "data" => %{ + "target" => %Asset{ + sys: %SysData{ + id: "7orLdboQQowIUs22KAW4U" + }, + fields: %Asset.Fields{ + title: "Sparkler", + description: "John with Sparkler", + file: %{ + content_type: "image/jpeg", + details: %{ + "image" => %{ + "height" => 2000, + "width" => 3000 + }, + "size" => 2_293_094 + }, + file_name: "matt-palmer-254999.jpg", + url: %URI{ + host: "images.ctfassets.net", + path: + "/gtrsnz13drim/7orLdboQQowIUs22KAW4U/ae1e04accdfcf6c3def7a449d12bff4c/matt-palmer-254999.jpg" + } + } + } + } + }, + "nodeType" => "embedded-asset-block" + }, + %{ + "content" => [ + %{"data" => %{}, "marks" => [], "nodeType" => "text", "value" => ""} + ], + "data" => %{}, + "nodeType" => "paragraph" + } + ], + "data" => %{}, + "nodeType" => "document" + } + } + } = entry |> LinkResolver.replace_links_with_entities(includes) + end + + test "ignores unknown LinkTypes we don't know how to parse even if matching entity exists in includes" do + entry = %Entry{ + fields: %{ + "author" => %{ + "sys" => %{ + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "linkType" => "Unknown", + "type" => "Link" + } + } + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "blogPost"} + } + } + + includes = %{ + "Unknown" => [ + %{ + "fields" => %{ + "company" => "ACME", + "email" => "john@doe.com", + "facebook" => "johndoe" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "person", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "id" => "15jwOBqpxqSAOy2eOO4S0m", + "type" => "Entry", + "updatedAt" => "2020-04-18T18:44:10.435Z" + } + } + ] + } + + ^entry = entry |> LinkResolver.replace_links_with_entities(includes) + end + + test "resolves nested links within lists of Entries" do + entry = %Entry{ + fields: %{ + "blocks" => [ + %{ + "sys" => %{ + "id" => "2IqBemFvusQUTEcnB93jDO", + "linkType" => "Entry", + "type" => "Link" + } + } + ], + "slug" => "my-page-with-content-blocks", + "title" => "My Page with Content Blocks" + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "page"} + } + } + + includes = %{ + "Entry" => [ + %{ + "fields" => %{ + "features" => [ + %{ + "sys" => %{ + "id" => "7nPDsIzj69Ey8RRvjRh5yT", + "linkType" => "Entry", + "type" => "Link" + } + }, + %{ + "sys" => %{ + "id" => "2VzTGEENSvxVZbfnxDJv2C", + "linkType" => "Entry", + "type" => "Link" + } + } + ], + "title" => "Some features" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "blockIcons", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "createdAt" => "2024-04-27T13:09:31.838Z", + "id" => "2IqBemFvusQUTEcnB93jDO", + "locale" => "en-GB", + "revision" => 1, + "type" => "Entry", + "updatedAt" => "2024-04-27T13:55:14.757Z" + } + }, + %{ + "fields" => %{ + "iconEmoji" => "😋", + "title" => "It is tasty" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "componentFeatures", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "createdAt" => "2024-04-27T13:50:54.844Z", + "id" => "2VzTGEENSvxVZbfnxDJv2C", + "locale" => "en-GB", + "revision" => 1, + "type" => "Entry", + "updatedAt" => "2024-04-27T13:51:11.254Z" + } + }, + %{ + "fields" => %{ + "iconEmoji" => "🥳", + "title" => "It is fun" + }, + "sys" => %{ + "contentType" => %{ + "sys" => %{ + "id" => "componentFeatures", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "createdAt" => "2024-04-27T13:49:44.958Z", + "id" => "7nPDsIzj69Ey8RRvjRh5yT", + "locale" => "en-GB", + "revision" => 1, + "type" => "Entry", + "updatedAt" => "2024-04-27T13:50:40.939Z" + } + } + ] + } + + %Entry{ + fields: %{ + "blocks" => [ + %Entry{ + fields: %{ + "features" => [ + %Entry{ + fields: %{ + "iconEmoji" => "🥳", + "title" => "It is fun" + } + }, + %Entry{ + fields: %{ + "iconEmoji" => "😋", + "title" => "It is tasty" + } + } + ] + } + } + ], + "slug" => "my-page-with-content-blocks", + "title" => "My Page with Content Blocks" + }, + sys: %SysData{ + id: "2PtC9h1YqIA6kaUaIsWEQ0", + revision: 2, + locale: "en-US", + updated_at: "2020-04-18T18:44:10.843Z", + created_at: "2019-03-22T08:33:45.069Z", + content_type: %ContentType{id: "page"} + } + } = LinkResolver.replace_links_with_entities(entry, includes) + end + end +end diff --git a/test/contentful_delivery/entries_test.exs b/test/contentful_delivery/entries_test.exs index 6836baa..5144229 100644 --- a/test/contentful_delivery/entries_test.exs +++ b/test/contentful_delivery/entries_test.exs @@ -138,5 +138,38 @@ defmodule Contentful.Delivery.EntriesTest do |> fetch_all(@space_id, @env, @access_token) end end + + test "will resolve links and embed them directly in the Entry" do + use_cassette "some entries have links" do + entry_id = "2PtC9h1YqIA6kaUaIsWEQ0" + + {:ok, + [ + %Entry{ + sys: %SysData{ + id: ^entry_id + }, + fields: %{ + "author" => %Entry{ + fields: %{ + "name" => "John Doe", + "title" => "Web Developer", + "email" => "john@doe.com" + } + } + } + } + ], + total: 1} = + Entries + |> content_type("blogPost") + |> by(id: entry_id) + |> fetch_all( + @space_id, + @env, + @access_token + ) + end + end end end