From a24a142b5bdbdc4ebc809538bd24ac4504bf1bb4 Mon Sep 17 00:00:00 2001 From: Will Rocha-Thomas Date: Mon, 6 May 2024 15:10:16 +0100 Subject: [PATCH] Replace links in "fields" with linked to entites from "includes" - Resolve all link fields found within "items" in an API response, and replace the links with the 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 - Breaking change: 'assets' field on Entry struct is no longer populated. All Assets for an Entry are embedded directly within 'fields' now instead --- CHANGELOG.md | 65 +-- .../some_entries_have_links.json | 57 +++ lib/contentful/entry.ex | 12 +- lib/contentful/entry/asset_resolver.ex | 54 --- lib/contentful/entry/link_resolver.ex | 114 +++++ lib/contentful/sys_data.ex | 20 +- lib/contentful_delivery/entries.ex | 40 +- mix.exs | 2 +- test/contentful/entry/asset_resolver_test.exs | 132 ----- test/contentful/entry/link_resolver_test.exs | 453 ++++++++++++++++++ test/contentful_delivery/entries_test.exs | 33 ++ 11 files changed, 726 insertions(+), 256 deletions(-) create mode 100644 fixture/vcr_cassettes/some_entries_have_links.json delete mode 100644 lib/contentful/entry/asset_resolver.ex create mode 100644 lib/contentful/entry/link_resolver.ex delete mode 100644 test/contentful/entry/asset_resolver_test.exs create mode 100644 test/contentful/entry/link_resolver_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c55cde..02c330e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,44 +2,49 @@ ## Unreleased +## 1.0.0 + +- Resolve all link fields found within "items" in an API response, and replace the links with the 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 +- Breaking change: 'assets' field on Entry struct is no longer populated. All Assets for an Entry are embedded directly within 'fields' now 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 +52,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/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..9446b0a 100644 --- a/lib/contentful/entry.ex +++ b/lib/contentful/entry.ex @@ -6,13 +6,17 @@ 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() } + + @deprecated "assets are no longer populated on an Entry, instead all links within 'fields' are replaced automatically with actual entities (if available in 'includes')" + def assets() do + [] + end 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..e9da6a4 --- /dev/null +++ b/lib/contentful/entry/link_resolver.ex @@ -0,0 +1,114 @@ +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.Entry + alias Contentful.Delivery.{Assets, Entries, Spaces, ContentTypes, Locales} + + @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 resolve_links(Entry.t(), map()) :: Entry.t() + def resolve_links(%Entry{fields: fields} = entry, %{} = includes) do + updated_fields = + fields + |> Enum.reduce(%{}, fn {name, value}, fields_with_links_resolved -> + new_value = + case resolved = resolve_link_field(value, includes) do + %Entry{} -> + resolve_links(resolved, includes) + + _ -> + resolved + end + + Map.put(fields_with_links_resolved, name, new_value) + end) + + struct(entry, fields: updated_fields) + end + + def resolve_links(entity, _includes), do: entity + + defp resolve_link_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 + defp resolve_link_field(%_{} = field_value, _includes), do: field_value + + # matches any other map that isn't a struct, maps can be iterated through using Enum + defp resolve_link_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 = + case resolved = resolve_link_field(nested_field_value, includes) do + %Entry{} -> + resolve_links(resolved, includes) + + %_{} -> + resolved + + %{} -> + resolve_link_field(resolved, includes) + + [] -> + resolve_link_field(resolved, includes) + + _ -> + resolved + end + + Map.put(field_with_nested_links_resolved, nested_field_name, updated_nested_field_value) + end) + end + + defp resolve_link_field(field_value, %{} = includes) + when is_list(field_value) and length(field_value) > 0 do + field_value + |> Enum.map(fn field -> + resolve_link_field(field, includes) + end) + end + + defp resolve_link_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..0d9fa66 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,19 @@ 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.resolve_links(includes) end), + total: total} end @doc """ @@ -133,17 +130,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..052a4a8 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 "1.0.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..1ea0585 --- /dev/null +++ b/test/contentful/entry/link_resolver_test.exs @@ -0,0 +1,453 @@ +defmodule Contentful.Entry.LinkResolverTest do + use ExUnit.Case + + alias Contentful.Asset + alias Contentful.Entry.LinkResolver + alias Contentful.{Entry, SysData, ContentType} + + describe "resolve_links/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.resolve_links(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.resolve_links(%{}) + 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.resolve_links(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.resolve_links(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.resolve_links(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