├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── sbt-dependency-graph.yaml ├── .gitignore ├── .tool-versions ├── CODEOWNERS ├── LICENSE ├── README.md ├── build.sbt ├── client-default └── src │ ├── main │ └── scala │ │ └── com.gu.contentapi.client │ │ └── GuardianContentClient.scala │ └── test │ └── scala │ └── com.gu.contentapi.client │ ├── GuardianContentClientBackoffTest.scala │ └── GuardianContentClientTest.scala ├── client ├── CHANGELOG.md └── src │ ├── main │ └── scala │ │ └── com.gu.contentapi.client │ │ ├── BackoffStrategy.scala │ │ ├── ContentApiClient.scala │ │ ├── Parameter.scala │ │ ├── Parameters.scala │ │ ├── ScheduledExecutor.scala │ │ ├── model │ │ ├── ContentApiError.scala │ │ ├── Decoder.scala │ │ ├── HttpResponse.scala │ │ ├── Queries.scala │ │ └── package.scala │ │ ├── thrift │ │ └── ThriftDeserializer.scala │ │ └── utils │ │ ├── CapiModelEnrichment.scala │ │ ├── DesignType.scala │ │ ├── QueryStringParams.scala │ │ └── format │ │ ├── Design.scala │ │ ├── Display.scala │ │ └── Theme.scala │ └── test │ └── scala │ ├── com.gu.contentapi.client │ ├── BackoffTest.scala │ ├── ContentApiClientTest.scala │ ├── HttpRetryTest.scala │ ├── RetryTest.scala │ └── model │ │ ├── ContentApiErrorTest.scala │ │ ├── ContentApiQueryTest.scala │ │ ├── VideoStatsQueryTest.scala │ │ └── utils │ │ └── CapiModelEnrichmentTest.scala │ └── com │ └── gu │ └── contentapi │ └── client │ └── utils │ └── QueryStringParamsTest.scala ├── docs ├── images │ └── pre-release-view.png └── release.md ├── project ├── Dependencies.scala ├── build.properties └── plugins.sbt └── version.sbt /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | 6 | # triggering CI default branch improves caching 7 | # see https://docs.github.com/en/free-pro-team@latest/actions/guides/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | id-token: write 17 | contents: read 18 | checks: write 19 | pull-requests: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: sbt/setup-sbt@v1.1.0 24 | 25 | - name: Test 26 | env: 27 | CAPI_TEST_KEY: ${{ secrets.CAPI_TEST_KEY }} 28 | JAVA_OPTS: -XX:+UseCompressedOops 29 | run: sbt test 30 | 31 | - uses: EnricoMi/publish-unit-test-result-action@v2 32 | if: always() #runs even if there is a test failure 33 | with: 34 | files: test-results/**/TEST-*.xml 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | uses: guardian/gha-scala-library-release-workflow/.github/workflows/reusable-release.yml@v1 9 | permissions: { contents: write, pull-requests: write } 10 | secrets: 11 | SONATYPE_TOKEN: ${{ secrets.AUTOMATED_MAVEN_RELEASE_SONATYPE_TOKEN }} 12 | PGP_PRIVATE_KEY: ${{ secrets.AUTOMATED_MAVEN_RELEASE_PGP_SECRET }} 13 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.AUTOMATED_MAVEN_RELEASE_GITHUB_APP_PRIVATE_KEY }} 14 | -------------------------------------------------------------------------------- /.github/workflows/sbt-dependency-graph.yaml: -------------------------------------------------------------------------------- 1 | name: Update Dependency Graph for sbt 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | jobs: 8 | dependency-graph: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout branch 12 | id: checkout 13 | uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 14 | - name: Install Java 15 | id: java 16 | uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0 17 | with: 18 | distribution: corretto 19 | java-version: 17 20 | - name: Install sbt 21 | id: sbt 22 | uses: sbt/setup-sbt@8a071aa780c993c7a204c785d04d3e8eb64ef272 # v1.1.0 23 | - name: Submit dependencies 24 | id: submit 25 | uses: scalacenter/sbt-dependency-submission@64084844d2b0a9b6c3765f33acde2fbe3f5ae7d3 # v3.1.0 26 | - name: Log snapshot for user validation 27 | id: validate 28 | run: cat ${{ steps.submit.outputs.snapshot-json-path }} | jq 29 | permissions: 30 | contents: write 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.travis.yml 4 | 5 | target/ 6 | project/metals.sbt 7 | project/project/ 8 | 9 | !.github -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | java corretto-21.0.3.9.1 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @guardian/content-platforms 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2014 Guardian News & Media Ltd 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Content API Scala Client 2 | ======================== 3 | 4 | [![content-api-client-default Scala version support](https://index.scala-lang.org/guardian/content-api-scala-client/content-api-client-default/latest-by-scala-version.svg?platform=jvm)](https://index.scala-lang.org/guardian/content-api-scala-client/content-api-client) 5 | [![Release](https://github.com/guardian/content-api-scala-client/actions/workflows/release.yml/badge.svg)](https://github.com/guardian/content-api-scala-client/actions/workflows/release.yml) 6 | 7 | A Scala client for the Guardian's [Content API](http://explorer.capi.gutools.co.uk/). 8 | 9 | 10 | ## Setup 11 | 12 | Add the following line to your SBT build definition, and set the version number to be the latest from the [releases page](https://github.com/guardian/content-api-scala-client/releases): 13 | 14 | ```scala 15 | libraryDependencies += "com.gu" %% "content-api-client-default" % "x.y.z" 16 | ``` 17 | 18 | Please note; 19 | 20 | - as of version 7.0, the content api scala client no longer supports java 7. 21 | 22 | - as of version 17.24.0 the content api scala client only supports scala 2.12+ 23 | 24 | If you don't have an API key, go to [open-platform.theguardian.com/access/](http://open-platform.theguardian.com/access/) to get one. You will then need to create a new instance of the client and set the key: 25 | 26 | ```scala 27 | val client = new GuardianContentClient("your-api-key") 28 | ``` 29 | 30 | ### Setup with custom Http layer 31 | 32 | As of version 12.0, the core module does not provide an http implementation. This is to accomodate use cases where people want to use their existing infrastructure, rather than relying on an extra dependency on OkHttp (the client used in the default module above). First, add the following line to your SBT definition: 33 | 34 | ```scala 35 | libraryDependencies += "com.gu" %% "content-api-client" % "x.y" 36 | ``` 37 | 38 | Then, create your own client by extending the `ContentApiClient` trait and implementing the `get` method, e.g. using Play's ScalaWS client library 39 | 40 | Note that as of version 17.0, `ContentApiClient` no longer enforces backoff strategy. We have decoupled the client from the retry-backoff logic (More on this, later in the README) A sample implementation is shown below and more examples can be found later in this readme. 41 | 42 | ```scala 43 | import play.api.libs.ws.WSClient 44 | 45 | class ContentApiClient(ws: WSClient) extends ContentApiClient 46 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = 47 | ws.url(url).withHttpHeaders(headers: _*).get.map(r => HttpResponse(r.bodyAsBytes, r.status, r.statusText)) 48 | } 49 | ``` 50 | 51 | ## Usage 52 | 53 | There are then four different types of query that can be performed: for a single item, or to filter through content, tags, or sections. You make a request of the Content API by creating a query and then using the client to get a response, which will come back in a `Future`. 54 | 55 | Use these imports for the following code samples (substituting your own execution context for real code): 56 | 57 | ```scala 58 | import com.gu.contentapi.client.GuardianContentClient 59 | import scala.concurrent.ExecutionContext.Implicits.global 60 | ``` 61 | 62 | ### Single item 63 | 64 | Every item on http://www.theguardian.com/ can be retrieved on the same path at https://content.guardianapis.com/. They can be either content items, tags, or sections. For example: 65 | 66 | ```scala 67 | // query for a single content item and print its web title 68 | val itemQuery = ContentApiLogic.item("commentisfree/2013/jan/16/vegans-stomach-unpalatable-truth-quinoa") 69 | client.getResponse(itemQuery).foreach { itemResponse => 70 | println(itemResponse.content.get.webTitle) 71 | } 72 | 73 | // print web title for a tag 74 | val tagQuery = ContentApiLogic.item("music/metal") 75 | client.getResponse(tagQuery).foreach { tagResponse => 76 | println(tagResponse.tag.get.webTitle) 77 | } 78 | 79 | // print web title for a section 80 | val sectionQuery = ContentApiLogic.item("environment") 81 | client.getResponse(sectionQuery).foreach { sectionResponse => 82 | println(sectionResponse.section.get.webTitle) 83 | } 84 | ``` 85 | 86 | Individual content items contain information not available from the `/search` endpoint described below. For example: 87 | 88 | ```scala 89 | // print the body of a given content item 90 | val itemBodyQuery = ContentApiLogic.item("politics/2014/sep/15/putin-bad-as-stalin-former-defence-secretary") 91 | .showFields("body") 92 | client.getResponse(itemBodyQuery) map { response => 93 | for (fields <- response.content.get.fields) println(fields.body) 94 | } 95 | 96 | // print the web title of every tag a content item has 97 | val itemWebTitleQuery = ContentApiLogic.item("environment/2014/sep/14/invest-in-monitoring-and-tagging-sharks-to-prevent-attacks") 98 | .showTags("all") 99 | client.getResponse(itemWebTitleQuery) map { response => 100 | for (tag <- response.content.get.tags) println(tag.webTitle) 101 | } 102 | 103 | // print the web title of the most viewed content items from the world section 104 | val mostViewedTitleQuery = ContentApiLogic.item("world").showMostViewed() 105 | client.getResponse(mostViewedTitleQuery) map { response => 106 | for (result <- response.mostViewed.get) println(result.webTitle) 107 | } 108 | ``` 109 | 110 | ### Content 111 | 112 | Filtering or searching for multiple content items happens at https://content.guardianapis.com/search. For example: 113 | 114 | ```scala 115 | // print the total number of content items 116 | val allContentSearch = ContentApiLogic.search 117 | client.getResponse(allContentSearch) map { response => 118 | println(response.total) 119 | } 120 | 121 | // print the web titles of the 15 most recent content items 122 | val lastFifteenSearch = ContentApiLogic.search.pageSize(15) 123 | client.getResponse(lastFifteenSearch) map { response => 124 | for (result <- response.results) println(result.webTitle) 125 | } 126 | 127 | // print the web titles of the 10 most recent content items matching a search term 128 | val toastSearch = ContentApiLogic.search.q("cheese on toast") 129 | client.getResponse(toastSearch) map { response => 130 | for (result <- response.results) println(result.webTitle) 131 | } 132 | 133 | // print the web titles of the 10 (default page size) most recent content items with certain tags 134 | val tagSearch = ContentApiLogic.search.tag("lifeandstyle/cheese,type/gallery") 135 | client.getResponse(tagSearch) map { response => 136 | for (result <- response.results) println(result.webTitle) 137 | } 138 | 139 | // print the web titles of the 10 most recent content items in the world section 140 | val sectionSearch = ContentApiLogic.search.section("world") 141 | client.getResponse(sectionSearch) map { response => 142 | for (result <- response.results) println(result.webTitle) 143 | } 144 | 145 | // print the web titles of the last 10 content items published a week ago 146 | import java.time.temporal.ChronoUnit 147 | import java.time.Instant 148 | val timeSearch = ContentApiLogic.search.toDate(Instant.now().minus(7, ChronoUnit.DAYS)) 149 | client.getResponse(timeSearch) map { response => 150 | for (result <- response.results) println(result.webTitle) 151 | } 152 | 153 | // print the web titles of the last 10 content items published whose type is article 154 | val typeSearch = ContentApiLogic.search.contentType("article") 155 | client.getResponse(typeSearch) map { response => 156 | for (result <- response.results) println(result.webTitle) 157 | } 158 | ``` 159 | 160 | ### Tags 161 | 162 | Filtering or searching for multiple tags happens at http://content.guardianapis.com/tags. For example: 163 | 164 | ```scala 165 | // print the total number of tags 166 | val allTagsQuery = ContentApiLogic.tags 167 | client.getResponse(allTagsQuery) map { response => 168 | println(response.total) 169 | } 170 | 171 | // print the web titles of the first 50 tags 172 | val fiftyTagsQuery = ContentApiLogic.tags.pageSize(50) 173 | client.getResponse(fiftyTagsQuery) map { response => 174 | for (result <- response.results) println(result.webTitle) 175 | } 176 | 177 | // print the web titles and bios of the first 10 contributor tags which have them 178 | val contributorTagsQuery = ContentApiLogic.tags.tagType("contributor") 179 | client.getResponse(contributorTagsQuery) map { response => 180 | for (result <- response.results.filter(_.bio.isDefined)) { 181 | println(result.webTitle + "\n" + result.bio.get + "\n") 182 | } 183 | } 184 | 185 | // print the web titles and numbers of the first 10 books tags with ISBNs 186 | val isbnTagsSearch = ContentApiLogic.tags 187 | .section("books") 188 | .referenceType("isbn") 189 | .showReferences("isbn") 190 | client.getResponse(isbnTagsSearch) map { response => 191 | for (result <- response.results) { 192 | println(result.webTitle + " -- " + result.references.head.id) 193 | } 194 | } 195 | ``` 196 | 197 | ### Sections 198 | 199 | Filtering or searching for multiple sections happens at http://content.guardianapis.com/sections. For example: 200 | 201 | ```scala 202 | // print the web title of each section 203 | val allSectionsQuery = ContentApiLogic.sections 204 | client.getResponse(allSectionsQuery) map { response => 205 | for (result <- response.results) println(result.webTitle) 206 | } 207 | 208 | // print the web title of each section with 'network' in the title 209 | val networkSectionsQuery = ContentApiLogic.sections.q("network") 210 | client.getResponse(networkSectionsQuery) map { response => 211 | for (result <- response.results) println(result.webTitle) 212 | } 213 | ``` 214 | 215 | ### Editions 216 | 217 | Filtering or searching for multiple Editions happens at http://content.guardianapis.com/editions. For example: 218 | 219 | ```scala 220 | // print the apiUrl of each edition 221 | val allEditionsQuery = ContentApiLogic.editions 222 | client.getResponse(allEditionsQuery) map { response => 223 | for (result <- response.results) println(result.apiUrl) 224 | } 225 | 226 | // print the webUrl of the edition with 'US' in edition field. 227 | val usEditionsQuery = ContentApiLogic.editions.q("US") 228 | client.getResponse(usEditionsQuery) map { response => 229 | for (result <- response.results) println(result.webUrl) 230 | } 231 | ``` 232 | 233 | ### Removed Content 234 | 235 | This is removed in version 15.8 236 | 237 | ### Pagination 238 | The client allows you to paginate through results in the following ways: 239 | * `paginate(query)(f)` unfolds a query until there are no more page results to process. `f` is a pure function processing a CAPI response and `paginate` returns a list of processed responses (wrapped in a `Future`) 240 | * `paginateAccum(query)(f, g)` folds over the results and accumulates into a final result. `f` transforms a response into an accumulated result, `g` [multiplies](https://en.wikipedia.org/wiki/Semigroup) two results together 241 | * `paginateFold(query)(f, m)` folds over the results by accumulating a final result. `f` takes two parameters: a response and the accumulated result so far. 242 | 243 | E.g. the following simply sums the number of results: 244 | 245 | ``` 246 | val result: Future[Int] = client.paginateFold(query)(0){ (r: SearchResponse, t: Int) => r.results.length + t } 247 | ``` 248 | 249 | ## Retrying recoverable errors (backoff strategies) 250 | Sometimes the backend services that the client relies on can return HTTP failure results, and some of these are potentially recoverable within a relatively short period of time. 251 | Rather than immediately fail these requests by default as we have done previously, it is now possible to automatically retry those failures that may yield a successful result on a subsequent attempt. 252 | As of version 17.0 of the client you can apply a retry and a backoff strategy when instantiating a `ContentApiClient` instance by mixing in `RetryableContentApiClient` trait and providing a backoff strategy. 253 | 254 | The following strategies are available; 255 | 256 | ```scala 257 | ContentApiBackoff.doublingStrategy 258 | ContentApiBackoff.exponentialStrategy 259 | ContentApiBackoff.multiplierStrategy 260 | ContentApiBackoff.constantStrategy 261 | ``` 262 | 263 | To use these, it's simply a case of defining a duration and a number of retries you intend to make. And in the case of the multiplierStrategy, a `double` defining the factor by which to multiply the waiting time. 264 | 265 | Some examples: 266 | 267 | ```scala 268 | class ApiClient extends ContentApiClient { 269 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = { 270 | // your implemenetation 271 | } 272 | } 273 | ``` 274 | 275 | ```scala 276 | val apiClient = new ApiClient with RetryableContentApiClient { 277 | override implicit val executor = ScheduledExecutor() // or apply your own preferred executor 278 | 279 | // create a doubling backoff and retry strategy 280 | // we will wait for 250ms, 500ms, 1000ms, 2000ms and then 4000ms while recoverable errors are encountered 281 | private val retryDuration = Duration(250L, TimeUnit.MILLISECONDS) 282 | private val retryAttempts = 5 283 | override val backoffStrategy = ContentApiBackoff.doublingStrategy(retryDuration, retryAttempts) 284 | 285 | } 286 | ``` 287 | 288 | ```scala 289 | val apiClient = new ApiClient with RetryableContentApiClient { 290 | override implicit val executor = ScheduledExecutor() // or apply your own preferred executor 291 | 292 | // create a constant backoff and retry strategy 293 | // we will allow up to three attempts, each separated by one second 294 | val retryDuration = Duration(1000L, TimeUnit.MILLISECONDS) 295 | val retryAttempts = 3 296 | override val backoffStrategy = ContentApiBackoff.constantStrategy(retryDuration, retryAttempts) 297 | } 298 | ``` 299 | 300 | ```scala 301 | val apiClient = new ApiClient with RetryableContentApiClient { 302 | override implicit val executor = ScheduledExecutor() // or apply your own preferred executor 303 | 304 | // create an exponential backoff strategy 305 | // the duration is multiplied by the next power of two on each attempt, so: 200ms, 400ms, 800ms, 1600ms and 3200ms 306 | // this means a maximum waiting time of 6.2 seconds if the error doesn't clear before then 307 | val retrySchedule: Duration = Duration(100L, TimeUnit.MILLISECONDS) 308 | val retryCount: Int = 5 309 | override val backoffStrategy = ContentApiBackoff.exponentialStrategy(retrySchedule, retryCount) 310 | } 311 | ``` 312 | 313 | ```scala 314 | val apiClient = new ApiClient with RetryableContentApiClient { 315 | override implicit val executor = ScheduledExecutor() // or apply your own preferred executor 316 | 317 | // create a multiplier backoff strategy 318 | // here, the duration is multiplied by the factor between each retry 319 | // with a factor of 3.0, and 3 retries we will wait: 250ms, 750ms, 2250ms 320 | val retrySchedule: Duration = Duration(250L, TimeUnit.MILLISECONDS) 321 | val retryCount: Int = 3 322 | val retryFactor: Double = 3.0 323 | override val backoffStrategy: Backoff = Backoff.multiplierStrategy(retrySchedule, retryCount, retryFactor) 324 | } 325 | ``` 326 | 327 | Note that there are some minimum values that are enforced to prevent using excessively short waits, or attempts to circumvent the backoff strategy entirely by forcing immediate retries. 328 | 329 | `exponential` requires a minimum delay of 100ms 330 | 331 | `doubling`, `multiple` and `constant` strategies requires a minimum delay of 250ms 332 | 333 | All strategies will ensure at least one retry attempt is configured. 334 | 335 | No upper limits are enforced for retries, delays or multipliers, so exercise caution in case you accidentally configure your client to wait for a very long time. 336 | 337 | ### Which response codes will be retried by the backoff strategy 338 | 339 | Initially we have specified the following codes as potentially recoverable: 340 | 341 | * 408 - Request Timeout 342 | * 429 - Too Many Requests 343 | * 503 - Service Unavailable 344 | * 504 - Gateway Timeout 345 | * 509 - Bandwidth Limit Exceeded 346 | 347 | This list may be subject to change over time. 348 | 349 | ## Default client - GuardianContentClient 350 | 351 | We have provided a default client `GuardianContentClient` (okHttp as underlying HTTP layer) which has multi-variants for specifying backoff strategies 352 | 353 | ```scala 354 | val client = new GuardianContentClient("YOUR API KEY HERE") // default vanilla client with no retry mechanism 355 | val client = GuardianContentClient("YOUR API KEY HERE", yourBackoffstrategy) // default client with injectable backoff strategy 356 | val client = GuardianContentClient("YOUR API KEY HERE") // default client with hardcoded doubling backoff strategy 357 | 358 | ``` 359 | 360 | ## Explore in the REPL 361 | 362 | One easy way to get started with the client is to try it in the Scala REPL. 363 | 364 | First clone this repo, then run `sbt console` from the `client` directory. This will start a REPL with a few useful things imported for you, so you can get started quickly: 365 | 366 | ``` 367 | scala> val client = GuardianContentClient("YOUR API KEY HERE") 368 | client: com.gu.contentapi.client.GuardianContentClient = com.gu.contentapi.client.GuardianContentClient@3eb2a60 369 | 370 | scala> val query = ContentApiLogic.search.showTags("all") 371 | query: com.gu.contentapi.client.model.SearchQuery = SearchQuery(/search?show-tags=all) 372 | 373 | scala> val response = Await.result(client.getResponse(query), 5.seconds) 374 | SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". 375 | SLF4J: Defaulting to no-operation (NOP) logger implementation 376 | SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. 377 | response: com.gu.contentapi.client.model.v1.SearchResponse = 378 | SearchResponse(ok,developer,1853997,1,10,1,185400,newest,List(Content(politics/blog/live/2016/may/17/corbyn-more-popular-than-ever-with-labour-members-poll-suggests-politics-live,Liveblog,Some(politics),Some(Politics),Some(CapiDateTime(1463487146000,2016-05-17T12:12:26.000Z)),EU referendum: Boris Johnson accuses Cameron of making UK look like 'banana republic' - Politics live,https://www.theguardian.com/politics/blog/live/2016/may/17/corbyn-more-popular-than-ever-with-labour-members-poll-suggests-politics-live,https://content.guardianapis.com/politics/blog/live/2016/may/17/corbyn-more-popular-than-ever-with-labour-members-poll-suggests-politics-live,None,List(Tag(politics/series/politics-live-with-andrew-sparrow,Series,Some(po... 379 | 380 | scala> client.shutdown() 381 | ``` 382 | 383 | ## Serialisation / Deserialisation 384 | 385 | To serialise the models returned from the client, you can use the JSON encoder in [content-api-models](https://github.com/guardian/content-api-models). You will need to import circe as a dependency in `build.sbt` – 386 | 387 | ``` 388 | val circeVersion = "0.12.3" 389 | 390 | libraryDependencies ++= Seq( 391 | "io.circe" %% "circe-core", 392 | "io.circe" %% "circe-generic", 393 | "io.circe" %% "circe-parser" 394 | ).map(_ % circeVersion) 395 | ``` 396 | 397 | ... and then call the relevant method on the model. 398 | 399 | ```scala 400 | import com.gu.contentapi.json.CirceEncoders._ 401 | import io.circe.syntax._ 402 | 403 | // ... later ... 404 | 405 | val query = ContentApiClient.search.q("An example query") 406 | val str = client.getResponse(query).map(_.asJson.toString) 407 | ``` 408 | 409 | ## Running Tests 410 | 411 | Some tests require access to the API. See [Setup](#setup) for details on how to get one. 412 | 413 | The key needs to be passed to the tests either as a system property or an environment variable. 414 | ```sh 415 | $ sbt -DCAPI_TEST_KEY=your_api_key test 416 | # or 417 | $ env CAPI_TEST_KEY=your_api_key sbt test 418 | ``` 419 | 420 | ## Troubleshooting 421 | 422 | If you have any problems you can speak to other developers at the [Guardian API talk group] (http://groups.google.com/group/guardian-api-talk). 423 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import ReleaseTransformations._ 3 | import Dependencies._ 4 | import sbtversionpolicy.withsbtrelease.ReleaseVersion 5 | 6 | 7 | val ghProject = "content-api-client" 8 | 9 | lazy val root = (project in file(".")) 10 | .aggregate(client, defaultClient) 11 | .settings( 12 | publish / skip := true, 13 | releaseVersion := ReleaseVersion.fromAggregatedAssessedCompatibilityWithLatestRelease().value, 14 | releaseProcess := Seq( 15 | checkSnapshotDependencies, 16 | inquireVersions, 17 | runClean, 18 | setReleaseVersion, 19 | commitReleaseVersion, 20 | tagRelease, 21 | setNextVersion, 22 | commitNextVersion 23 | ) 24 | ) 25 | 26 | lazy val client = (project in file("client")) 27 | .settings(artifactProductionSettings, clientSettings) 28 | .enablePlugins(BuildInfoPlugin) 29 | 30 | lazy val defaultClient = (project in file("client-default")) 31 | .dependsOn(client) 32 | .settings(artifactProductionSettings, defaultClientSettings) 33 | 34 | 35 | lazy val artifactProductionSettings: Seq[Setting[_]] = Seq( 36 | crossScalaVersions := scalaVersions, 37 | scalaVersion := scalaVersions.max, 38 | scalacOptions ++= Seq("-deprecation", "-unchecked", "-release:8"), 39 | licenses := Seq(License.Apache2), 40 | organization := "com.gu" 41 | ) 42 | 43 | lazy val clientSettings: Seq[Setting[_]] = Seq( 44 | name := ghProject, 45 | description := "Scala client for the Guardian's Content API", 46 | buildInfoKeys := Seq[BuildInfoKey](version), 47 | buildInfoPackage := "com.gu.contentapi.buildinfo", 48 | buildInfoObject := "CapiBuildInfo", 49 | libraryDependencies ++= clientDeps 50 | ) 51 | 52 | lazy val defaultClientSettings: Seq[Setting[_]] = Seq( 53 | name := ghProject + "-default", 54 | description := "Default scala client for the Guardian's Content API", 55 | libraryDependencies ++= clientDeps ++ defaultClientDeps, 56 | console / initialCommands := """ 57 | import com.gu.contentapi.client._ 58 | import scala.concurrent.ExecutionContext.Implicits.global 59 | import scala.concurrent.Await 60 | import scala.concurrent.duration._ 61 | """ 62 | ) 63 | 64 | Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-u", s"test-results/scala-${scalaVersion.value}", "-o") 65 | -------------------------------------------------------------------------------- /client-default/src/main/scala/com.gu.contentapi.client/GuardianContentClient.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.io.IOException 4 | import java.util.concurrent.TimeUnit 5 | 6 | import com.gu.contentapi.client.model.HttpResponse 7 | import okhttp3._ 8 | 9 | import scala.concurrent.duration.Duration 10 | import scala.concurrent.{ExecutionContext, Future, Promise} 11 | 12 | object GuardianContentClient { 13 | def apply(apiKey: String): GuardianContentClient = { 14 | implicit val executor = ScheduledExecutor() 15 | val strategy = BackoffStrategy.doublingStrategy(Duration(250L, TimeUnit.MILLISECONDS), 5) 16 | GuardianContentClient(apiKey, strategy) 17 | } 18 | 19 | def apply(apiKey: String, clientBackoffStrategy: BackoffStrategy)(implicit executor0: ScheduledExecutor): GuardianContentClient = 20 | new GuardianContentClient(apiKey) with RetryableContentApiClient { 21 | override val backoffStrategy: BackoffStrategy = clientBackoffStrategy 22 | override implicit val executor: ScheduledExecutor = executor0 23 | } 24 | } 25 | 26 | class GuardianContentClient (val apiKey: String) extends ContentApiClient { 27 | 28 | protected def httpClientBuilder = new OkHttpClient.Builder() 29 | .connectTimeout(1, TimeUnit.SECONDS) 30 | .readTimeout(2, TimeUnit.SECONDS) 31 | .followRedirects(true) 32 | .connectionPool(new ConnectionPool(10, 60, TimeUnit.SECONDS)) 33 | 34 | protected val http = httpClientBuilder.build 35 | 36 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = { 37 | 38 | val reqBuilder = new Request.Builder().url(url) 39 | val req = headers.foldLeft(reqBuilder) { 40 | case (r, (name, value)) => r.header(name, value) 41 | } 42 | 43 | val promise = Promise[HttpResponse]() 44 | 45 | http.newCall(req.build()).enqueue(new Callback() { 46 | override def onFailure(call: Call, e: IOException): Unit = promise.failure(e) 47 | override def onResponse(call: Call, response: Response): Unit = { 48 | promise.success(HttpResponse(response.body().bytes, response.code(), response.message())) 49 | } 50 | }) 51 | 52 | promise.future 53 | } 54 | 55 | /** Shutdown the client and clean up all associated resources. 56 | * 57 | * Note: behaviour is undefined if you try to use the client after calling this method. 58 | */ 59 | def shutdown(): Unit = http.dispatcher().executorService().shutdown() 60 | 61 | } -------------------------------------------------------------------------------- /client-default/src/test/scala/com.gu.contentapi.client/GuardianContentClientBackoffTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.io.IOException 4 | import java.util.concurrent.TimeUnit 5 | 6 | import com.gu.contentapi.client.model.{HttpResponse, ItemQuery} 7 | import okhttp3.{Call, Callback, Request, Response} 8 | import org.scalatest._ 9 | import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} 10 | import org.scalatest.exceptions.TestFailedException 11 | 12 | import scala.concurrent.ExecutionContext.Implicits.global 13 | import scala.concurrent.duration.Duration 14 | import scala.concurrent.{ExecutionContext, Future, Promise} 15 | import org.scalatest.flatspec.AnyFlatSpec 16 | import org.scalatest.matchers.should.Matchers 17 | 18 | object FakeGuardianContentClient { 19 | private final val ApiKeyProperty = "CAPI_TEST_KEY" 20 | private val apiKey: String = { 21 | Option(System.getProperty(ApiKeyProperty)) orElse Option(System.getenv(ApiKeyProperty)) 22 | }.orNull ensuring(_ != null, s"Please supply a $ApiKeyProperty as a system property or an environment variable e.g. sbt -D$ApiKeyProperty=some-api-key") 23 | } 24 | 25 | class FakeGuardianContentClient(retries: Int, failCode: Option[Int], alwaysFail: Boolean = false) extends GuardianContentClient(FakeGuardianContentClient.apiKey) { 26 | 27 | private var attempts = 0 28 | 29 | def attemptCount: Int = attempts 30 | 31 | override def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = { 32 | 33 | val reqBuilder = new Request.Builder().url(url) 34 | val req = headers.foldLeft(reqBuilder) { 35 | case (r, (name, value)) => r.header(name, value) 36 | }.build() 37 | 38 | val promise = Promise[HttpResponse]() 39 | 40 | attempts += 1 41 | http.newCall(req).enqueue(new Callback() { 42 | override def onFailure(call: Call, e: IOException): Unit = { 43 | // this is a genuinely unexpected failure - pass it back 44 | promise.failure(e) 45 | } 46 | override def onResponse(call: Call, response: Response): Unit = { 47 | try { 48 | failCode match { 49 | case Some(value) if attempts < retries || alwaysFail => 50 | val msg = s"Failed with recoverable result $value. This is intentional" 51 | promise.success(HttpResponse("Failed".getBytes(), value, msg)) 52 | case _ if alwaysFail => 53 | val msg = "Failed, unrecoverable. This is intentional" 54 | promise.failure(ContentApiBackoffException(msg)) 55 | case _ => 56 | val msg = response.message() 57 | promise.success(HttpResponse(response.body().bytes, response.code(), msg)) 58 | } 59 | } finally { 60 | response.body().close() // because we _may_ not have processed the response body above 61 | } 62 | } 63 | }) 64 | 65 | promise.future 66 | } 67 | 68 | } 69 | 70 | class GuardianContentClientBackoffTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with BeforeAndAfterAll with Inside with IntegrationPatience { 71 | 72 | private val TestItemPath = "commentisfree/2012/aug/01/cyclists-like-pedestrians-must-get-angry" 73 | 74 | implicit val executor0: ScheduledExecutor = ScheduledExecutor() 75 | 76 | "Client interface" should "succeed after two 408 retry attempts" in { 77 | val myInterval = 250L 78 | val myAttempts = 3 79 | val failureCode = 408 80 | val fakeApi = new FakeGuardianContentClient(myAttempts, Some(failureCode)) with RetryableContentApiClient { 81 | override val backoffStrategy: BackoffStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, TimeUnit.MILLISECONDS), myAttempts) 82 | override implicit val executor: ScheduledExecutor = executor0 83 | } 84 | 85 | val query = ItemQuery(TestItemPath) 86 | val content = for { 87 | response <- fakeApi.getResponse(query) 88 | } yield response.content.get 89 | content.futureValue.id should be (TestItemPath) 90 | fakeApi.shutdown() 91 | } 92 | 93 | it should "fail after three 429 retries" in { 94 | val myInterval = 500L 95 | val myRetries = 3 // i.e. try this once, and then make three retry attempts = 4 attempts in total 96 | val failureCode = 429 97 | val alwaysFail = true 98 | val fakeApi = new FakeGuardianContentClient(myRetries, Some(failureCode), alwaysFail) with RetryableContentApiClient { 99 | override val backoffStrategy: BackoffStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, TimeUnit.MILLISECONDS), myRetries) 100 | override implicit val executor: ScheduledExecutor = executor0 101 | } 102 | val query = ItemQuery(TestItemPath) 103 | 104 | val result = for { 105 | response <- fakeApi.getResponse(query) 106 | } yield response.content.get 107 | 108 | // there must be a nicer way to handle this 109 | val expectedExceptionMessage = "The future returned an exception of type: com.gu.contentapi.client.ContentApiBackoffException, with message: Backoff failed after 3 attempts." 110 | val caught = intercept[TestFailedException] { 111 | result.futureValue 112 | } 113 | assert(caught.getMessage == expectedExceptionMessage) 114 | 115 | fakeApi.shutdown() 116 | } 117 | 118 | it should "retry (successfully) all recoverable error codes" in { 119 | val myInterval = 250L 120 | val myRetries = 2 // i.e. try once, then retry once = 2 attempts total 121 | val query = ItemQuery(TestItemPath) 122 | 123 | HttpResponse.failedButMaybeRecoverableCodes.foreach(code => { 124 | val fakeApi = new FakeGuardianContentClient(myRetries, Some(code)) with RetryableContentApiClient { 125 | override val backoffStrategy: BackoffStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, TimeUnit.MILLISECONDS), myRetries) 126 | override implicit val executor: ScheduledExecutor = executor0 127 | } 128 | val content = for { 129 | response <- fakeApi.getResponse(query) 130 | } yield response.content.get 131 | content.futureValue.id should be (TestItemPath) 132 | fakeApi.attemptCount should be (myRetries) 133 | fakeApi.shutdown() 134 | }) 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /client-default/src/test/scala/com.gu.contentapi.client/GuardianContentClientTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.model.Direction.{Next, Previous} 4 | import com.gu.contentapi.client.model.v1.{ContentType, ErrorResponse, SearchResponse, TagsResponse} 5 | import com.gu.contentapi.client.model.{ContentApiError, FollowingSearchQuery, ItemQuery, SearchQuery} 6 | import com.gu.contentatom.thrift.{AtomData, AtomType} 7 | import org.scalatest._ 8 | import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} 9 | import org.scalatest.time.{Seconds, Span} 10 | 11 | import java.time.Instant 12 | import scala.concurrent.ExecutionContext.Implicits.global 13 | import org.scalatest.flatspec.AnyFlatSpec 14 | import org.scalatest.matchers.should.Matchers 15 | 16 | object GuardianContentClientTest { 17 | private final val ApiKeyProperty = "CAPI_TEST_KEY" 18 | private val apiKey: String = { 19 | Option(System.getProperty(ApiKeyProperty)) orElse Option(System.getenv(ApiKeyProperty)) 20 | }.orNull ensuring(_ != null, s"Please supply a $ApiKeyProperty as a system property or an environment variable e.g. sbt -D$ApiKeyProperty=some-api-key") 21 | } 22 | 23 | class GuardianContentClientTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with BeforeAndAfterAll with Inside with IntegrationPatience { 24 | import GuardianContentClientTest.apiKey 25 | 26 | private val api = new GuardianContentClient(apiKey) 27 | private val TestItemPath = "commentisfree/2012/aug/01/cyclists-like-pedestrians-must-get-angry" 28 | 29 | override def afterAll(): Unit = { 30 | api.shutdown() 31 | } 32 | 33 | implicit override val patienceConfig = PatienceConfig(timeout = Span(5, Seconds)) 34 | 35 | it should "successfully call the Content API" in { 36 | val query = ItemQuery(TestItemPath) 37 | val content = for { 38 | response <- api.getResponse(query) 39 | } yield response.content.get 40 | content.futureValue.id should be (TestItemPath) 41 | } 42 | 43 | it should "return errors as a broken promise" in { 44 | val query = ItemQuery("something-that-does-not-exist") 45 | val errorTest = api.getResponse(query) recover { case error => 46 | error should matchPattern { case ContentApiError(404, _, Some(ErrorResponse("error", "The requested resource could not be found."))) => } 47 | } 48 | errorTest.futureValue 49 | } 50 | 51 | it should "handle error responses" in { 52 | val query = SearchQuery().pageSize(500) 53 | val errorTest = api.getResponse(query) recover { case error => 54 | error should matchPattern { case ContentApiError(400, _, Some(ErrorResponse("error", "page-size must be an integer between 0 and 200"))) => } 55 | } 56 | errorTest.futureValue 57 | } 58 | 59 | it should "perform a given item query" in { 60 | val query = ItemQuery(TestItemPath) 61 | val content = for (response <- api.getResponse(query)) yield response.content.get 62 | content.futureValue.id should be (TestItemPath) 63 | } 64 | 65 | it should "perform a given sections query" in { 66 | val query = ContentApiClient.sections.q("business") 67 | val results = for (response <- api.getResponse(query)) yield response.results 68 | val fResults = results.futureValue 69 | fResults.size should be >= 3 // UK business, US business, all da business 70 | } 71 | 72 | it should "perform a given atoms query" in { 73 | val query = ContentApiClient.atoms.types("explainer") 74 | val results = for (response <- api.getResponse(query)) yield response.results 75 | val fResults = results.futureValue 76 | fResults.size should be (10) 77 | } 78 | 79 | it should "perform a given search query using the type filter" in { 80 | val query = ContentApiClient.search.contentType("article") 81 | val results = for (response <- api.getResponse(query)) yield response.results 82 | val fResults = results.futureValue 83 | fResults.size should be (10) 84 | fResults.foreach(_.`type` should be(ContentType.Article)) 85 | } 86 | 87 | it should "perform an atom query" in { 88 | val query = ItemQuery("/atom/quiz/3c244199-01a8-4836-a638-daabf9aca341") 89 | val quiz = for (response <- api.getResponse(query)) yield response.quiz.get 90 | val fQuiz = quiz.futureValue 91 | fQuiz.atomType should be (AtomType.Quiz) 92 | fQuiz.id should be ("3c244199-01a8-4836-a638-daabf9aca341") 93 | inside(fQuiz.data) { 94 | case AtomData.Quiz(data) => 95 | data.title should be ("Andy Burnham quiz") 96 | data.content.questions should have size 10 97 | } 98 | } 99 | 100 | it should "be able to find previous and next articles in a series" in { 101 | // This kind of functionality is used eg. for the DotCom image lightbox forward-backward buttons: 102 | // https://github.com/guardian/frontend/pull/20462 103 | val queryForNext = FollowingSearchQuery( 104 | ContentApiClient.search.tag("commentisfree/series/guardian-comment-cartoon").orderBy("oldest").pageSize(1), 105 | "commentisfree/picture/2022/may/15/nicola-jennings-boris-johnson-northern-ireland-cartoon", 106 | Next 107 | ) 108 | 109 | api.getResponse(queryForNext).futureValue.results.head.id shouldBe 110 | "commentisfree/picture/2022/may/16/steve-bell-on-the-queens-platinum-jubilee-cartoon" 111 | 112 | val queryForPrevious = queryForNext.copy(direction = Previous) 113 | 114 | api.getResponse(queryForPrevious).futureValue.results.head.id shouldBe 115 | "commentisfree/picture/2022/may/13/martin-rowson-whats-left-in-the-tories-box-of-tricks-cartoon" 116 | } 117 | 118 | it should "paginate through all results" in { 119 | val query = ContentApiClient.search 120 | .q("brexit") 121 | .fromDate(Instant.parse("2018-05-10T00:00:00.00Z")) 122 | .toDate(Instant.parse("2018-05-11T23:59:59.99Z")) 123 | .orderBy("oldest") 124 | // http://content.guardianapis.com/search?q=brexit&from-date=2018-05-10T00:00:00.00Z&to-date=2018-05-11T23:59:59.99Z 125 | // has 5 pages of results 126 | 127 | val result = api.paginate(query){ r: SearchResponse => r.results.length } 128 | 129 | result.futureValue should be (List(10, 10, 10, 10, 2)) 130 | } 131 | 132 | it should "sum up the number of results" in { 133 | val query = ContentApiClient.search 134 | .q("brexit") 135 | .fromDate(Instant.parse("2018-05-10T00:00:00.00Z")) 136 | .toDate(Instant.parse("2018-05-11T23:59:59.99Z")) 137 | .orderBy("newest") 138 | // http://content.guardianapis.com/search?q=brexit&from-date=2018-05-10T00:00:00.00Z&to-date=2018-05-11T23:59:59.99Z 139 | // has 5 pages of results 140 | 141 | val result = api.paginateAccum(query)({ r: SearchResponse => r.results.length }, { (a: Int, b: Int) => a + b }) 142 | 143 | result.futureValue should be (42) 144 | } 145 | 146 | it should "be able to paginate any query that supports paging, eg Tags!" in { 147 | val query = ContentApiClient.tags 148 | .tagType("newspaper-book") 149 | .section("music") 150 | .pageSize(5) 151 | // https://content.guardianapis.com/tags?type=newspaper-book§ion=music&page-size=5 152 | // has 19 results as of May 2022 153 | 154 | val result = api.paginateAccum(query)({ r: TagsResponse => r.results.length }, { (a: Int, b: Int) => a + b }) 155 | 156 | result.futureValue should be > 10 157 | } 158 | 159 | it should "fold over the results" in { 160 | val query = ContentApiClient.search 161 | .q("brexit") 162 | .fromDate(Instant.parse("2018-05-10T00:00:00.00Z")) 163 | .toDate(Instant.parse("2018-05-11T23:59:59.99Z")) 164 | .orderBy("newest") 165 | // http://content.guardianapis.com/search?q=brexit&from-date=2018-05-10T00:00:00.00Z&to-date=2018-05-11T23:59:59.99Z 166 | // has 5 pages of results 167 | 168 | val result = api.paginateFold(query)(0){ (r: SearchResponse, t: Int) => r.results.length + t } 169 | 170 | result.futureValue should be (42) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Latest information in GitHub Releases 2 | 3 | Check out **https://github.com/guardian/content-api-scala-client/releases** 4 | for info on all releases from v19.0.0 onwards. For historical reference 5 | purposes, information on prior releases is included below. 6 | 7 | ### Releases prior to v19.0.0... 8 | 9 | ## 18.0.1 10 | 11 | ### Bug fixes 12 | 13 | * Prioritise interactives over newsletter sign-ups in the parsing of `Design`. There are some interactives that are also newsletter sign-ups; they don't work properly unless they're treated as interactives. (https://github.com/guardian/content-api-scala-client/pull/356) 14 | 15 | ## 18.0.0 16 | 17 | ### Breaking changes 18 | 19 | * Split `Design.Media` into `Design.Gallery`, `Design.Audio` and `Design.Video` (https://github.com/guardian/content-api-scala-client/pull/353) 20 | * Prioritise blogs over match reports in the parsing of `Design`. There are some old deadblogs that are also tagged as match reports; they should be treated as blogs. (https://github.com/guardian/content-api-scala-client/pull/355) 21 | 22 | ## 17.25.2 23 | * Rework modelling of `Newsletter` designs: 24 | * Remove `NewsletterSignup` from `DesignType` 25 | * Remove `NewsletterDesign` from `Design` 26 | 27 | ## 17.25.1 28 | * Support styling of new newsletter-tone articles: 29 | * Add `Newsletter` to `DesignType` 30 | * Add `NewsletterDesign` to `Design` 31 | 32 | ## 17.24.0 33 | * Bump CAPI models to 17.3.0 to take advantage of upstream dependency updates 34 | * Switch to three part semantic versioning to bring into line with other projects 35 | * This build should be functionally identical to 17.23 but updates to thrift *could* introduce unexpected effects 36 | * This build no longer supports Scala < 2.12 due to upstream dependencies also dropping support for earlier versions 37 | 38 | ## 17.23 39 | * Map old "immersive interactive" articles to display: standard, design: full page interactive 40 | * Map new "immersive interactive" articles to display: immersive, design: interactive, to use the new template 41 | 42 | ## 17.21 43 | * Remove This is Europe tag from Special Report in format 44 | 45 | ## 17.20 46 | * Bump CAPI models to 17.1.0 47 | * updates embed type elements to include 'caption' field 48 | 49 | ## 17.19 50 | 51 | * Bump CAPI models to 17.0.0 52 | 53 | ## 17.18 54 | 55 | * Bump CAPI models to 16.1.0 (upgrades thrift to 0.13.0) 56 | * Bump our own import of thrift to 0.13.0 also 57 | 58 | ## 17.17 59 | 60 | * Fix bug with ordering of some predicate in model enrichment 61 | * Prioritise NumberedList over Showcase 62 | * Special reports and labs should override standard pillars. 63 | 64 | ## 17.16 65 | 66 | * Bump CAPI models to 15.10.2 67 | * updates `blocks` field to include code type elements on the body 68 | 69 | ## 17.15 70 | 71 | * Correct Letters content to have `OpinionPillar` instead of `NewsPillar` when tag is present. 72 | 73 | ## 17.14 74 | 75 | * Fix bug with ordering of predicates in Design enrichment for Features and PhotoEssays 76 | * Make PhotoEssays Immersive Display 77 | 78 | ## 17.13 79 | 80 | * Fix bug with ordering of predicates in Design enrichment for Features and Interviews 81 | 82 | ## 17.12 83 | 84 | * Add support for `paths` SearchQuery parameter (e.g. `search?paths=path/one,path/two`) 85 | 86 | ## 17.11 87 | 88 | * Bump CAPI models to 15.10.0 89 | * updates `aliasPaths` definition to include `ceasedToBeCanonicalAt` datetime 90 | 91 | ## 17.10 92 | 93 | * Add `Format` to `CapiModelEnrichment`. 94 | * This adds the `Design`, `Display` and `Theme` traits to be consumed by platforms. 95 | 96 | ## 17.9 97 | 98 | * Adds `showAliasPaths` to `ShowParameters` 99 | 100 | ## 17.8 101 | 102 | * Bump CAPI models to 15.9.14 103 | * 15.9.13 adds `sourceDomain` to `*ElementFields` 104 | * 15.9.14 removes `height` and `width` fields from `PullquoteElementFields`, `TweetElementFields`, 105 | `AudioElementFields`, `InteractiveElementFields`, `EmbedElementFields` and `InstagramElementFields` 106 | 107 | ## 17.7 108 | 109 | * Bump CAPI models to 15.9.12 110 | * 15.9.10 adds `deletedContent` payload type with optional `aliasPaths` to delete events 111 | * 15.9.11 adds `source` fields to `PullquoteElementFields` and `EmbedElementFields` 112 | * 15.9.12 adds `height` and `width` fields to `PullquoteElementFields`, `TweetElementFields`, `AudioElementFields`, 113 | `InteractiveElementFields`, `EmbedElementFields` and `InstagramElementFields` 114 | 115 | ## 17.6 116 | 117 | * Bump CAPI model to 15.9.7 (adds additional fields to RetrievableEvent) 118 | 119 | ## 17.5 120 | 121 | * Bump CAPI model to 15.9.6 (adds block element role fields) 122 | 123 | ## 17.2 124 | 125 | * Bump CAPI models to 15.9.2 (adds optional `placeholderUrl` field to interactive atoms) 126 | 127 | ## 17.1 128 | 129 | * Removed 503 from list of retryable response codes 130 | 131 | ## 17.0 132 | 133 | * Removed `backoffStrategy` from `ContentApiClient`. Introduced `RetryableContentApiClient` to mix-in a retry backoff strategy. More in README. 134 | 135 | ## 16.0 136 | 137 | * Add a `Request-Attempt` header that is set to zero for any initial request and incremented by one for each retry - so that we can see the behaviour of the backoff code in Kibana 138 | 139 | ## 15.8 140 | 141 | * Bump CAPI models to 15.6 (removed content no longer supported) 142 | 143 | ## 15.7 144 | 145 | * Add AdvertisementFeature design type 146 | 147 | ## 15.6 148 | 149 | * Require clients to implement a ContentApiBackoff retry strategy along with an implicitly declared ScheduledExecutor. See the README for more info. 150 | 151 | ## 15.5 152 | 153 | * Upgrade model to v15.5 (adds acast ID to podcast metadata) 154 | 155 | ## 15.4 156 | 157 | * Upgrade model to v15.3 (removes storyquestions) 158 | 159 | ## 15.1–15.3 160 | 161 | Broken tag releases 162 | 163 | ## 15.0 164 | 165 | * Remove stories-related APIs 166 | * Upgrade model to v15.0 167 | 168 | ## 14.2 169 | 170 | * Version 14.1 of the models dependency was empty 171 | 172 | ## 14.0 (13.0 is skipped) 173 | 174 | * Upgrades to Scrooge 19.3, which uses libthrift 0.10 175 | 176 | ## 12.15 177 | * Add support for `use-date` parameter in atoms queries 178 | 179 | ## 12.14 180 | * Bump version of content-api-models to 12.14 (adds support for the audio atom). 181 | 182 | ## 12.10 183 | * Bump version of content-api-models to 12.10 (fixes namespacing of the new chart atom). 184 | 185 | ## 12.9 186 | * Fix a bug in the new atom usage API where the URL contained uppercase characters 187 | 188 | ## 12.8 189 | * [#277](https://github.com/guardian/content-api-scala-client/pull/277) Add support for atom usage queries 190 | * bump content-api-models to 12.8 (adds `ChartAtom`) 191 | 192 | ## 12.7 193 | * bump content-api-models to 12.7 (adds `googlePodcastsUrl` and `spotifyUrl` to the Tag Podcast model) 194 | 195 | ## 12.6 196 | * [#272](https://github.com/guardian/content-api-scala-client/pull/272) Adds ids parameter to RemovedContentQuery 197 | 198 | ## 12.5 199 | * bump content-api-models to 12.5 (adds `resultsWithLastModified` to `RemovedContentResponse`) 200 | 201 | ## 12.2 202 | 203 | ### New features 204 | 205 | * `paginate(query)(f)` unfolds a query until there are no more page results to process. `f` is a pure function processing a CAPI response and `paginate` returns a list of processed responses (wrappied in a `Future`) 206 | * `paginateAccum(query)(f, g)` folds over the results and accumulates into a final result. `f` transforms a response into an accumulated result, `g` [multiplies](https://en.wikipedia.org/wiki/Semigroup) two results together 207 | * `paginateFold(query)(f, m)` folds over the results by accumulating a final result. `f` takes two parameters: a response and the accumulated result so far. 208 | 209 | ## 12.1 210 | * Update content-api-models to 12.1 (new `campaign` tag type) 211 | * Allow OkHttp client to be overridden in the default client class 212 | 213 | ## 12.0 214 | 215 | ### Removed the dependency on OkHttp 216 | 217 | The content-api-client project now lacks a concrete implementation of the HTTP communication. That is the first step we take in cleaning up the client and making it more amenable to purely functional settings. As a result, the `ContentApiClient` trait is provided and users are required to provide a concrete implementation based on their preferred HTTP client library. The following method must be implemented: 218 | 219 | ```scala 220 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] 221 | ``` 222 | 223 | For convenience, the previous default implementation is provided in a separate project, content-api-client-default. Versioning of that package will follow the one of content-api-client and thus will start at version 12. 224 | 225 | ### Utility functions moved in companion 226 | 227 | The `item`, `search`, `tags`, `sections`, `editions`, `removedContent`, `atoms`, `recipes`, `reviews`, `gameReviews`, `restaurantReviews`, `filmReviews`, `videoStats` and `stories` methods have been moved into a separate trait, `ContentApiQueries`, from which the companion object `ContentApiClient` inherits. 228 | 229 | The previous behaviour can be replicated very simply: 230 | 231 | ```scala 232 | val client = new GuardianContentClient(...) with ContentApiQueries 233 | ``` 234 | 235 | ## 11.54 236 | * Update content-api-models to 11.51 (to get `showAffiliateLinks` field) 237 | 238 | ## 11.53 239 | * Support `SearchQueryBase` in `GuardianContentClient#getResponse`. 240 | 241 | ## 11.52 242 | * Extract a `SearchQueryBase` as the parent of `SearchQuery`. 243 | This helps with customising search queries (e.g. using a different path or more parameters). 244 | * Upgrade patch versions for Scala (2.12.4 and 2.11.12) and okhttp (3.9.1). 245 | * Set the target JVM to 1.8. 246 | * Fix regression of read and connect timeouts being 1000 and 2000 instead of 1 and 2 seconds 247 | 248 | ## 11.51 249 | * Encode query param spaces as %20 250 | 251 | ## 11.50 252 | * Use cats 1.0 (via content-api-models 11.50) 253 | 254 | ## 11.49 255 | * Update designType algorithm to prioritise immersive over everything else 256 | 257 | ## 11.48 258 | * Update content-api-models to 11.48 (new atom image asset fields and internalCommissionedWordcount) 259 | 260 | ## 11.46 261 | * Update content-api-models to 11.46 (commonsdivision in response) 262 | 263 | ## 11.45 264 | * Update content-api-models to 11.45 (commonsdivision atom) 265 | * Support new `DesignType`s 266 | 267 | ## 11.43 268 | * Update content-api-models to 11.43 (new embargo field on atoms) 269 | * Adds an implicit designType field to Content model 270 | 271 | ## 11.42 272 | * Update content-api-models to 11.42 (timeline atom flexible date formats) 273 | 274 | ## 11.40 275 | * Update content-api-models to 11.40 (atom image credit field) 276 | 277 | ## 11.39 278 | * Use https 279 | * Update content-api-models to 11.39 (scheduledLaunch field in atoms) 280 | 281 | ## 11.38 282 | Update content-api-models to 11.38 (add Pillars models) 283 | 284 | ## 11.37 285 | Add sponsorship-type filter to tags endpoint. 286 | 287 | ## 11.36 288 | Update content-api-models to 11.36 (add podcast type information) 289 | 290 | ## 11.35 291 | * Update content-api-models to 11.35 (add sponsorship information to rich link fields) 292 | 293 | ## 11.33 294 | * Move to using OkHTTP over Dispatch 295 | 296 | ## 11.32 297 | * Update content-api-models to 11.32 298 | * Replace Joda data types with their Java 8 equivalent 299 | 300 | ## 11.30 301 | * Update content-api-models to 11.30 (add `description` to Timeline atoms for context) 302 | 303 | ## 11.29 304 | * Update content-api-models to 11.29 (add `commissioningDesks` to atoms and `answers` to readers questions) 305 | 306 | ## 11.26 307 | * Update content-api-models to 11.28 (add validTo and validFrom dates to Sponsorship, present in both tags and sections.) 308 | * Add supported `sponsorship-type` filter for Section. 309 | 310 | ## 11.25 311 | * Update content-api-models to 11.25 (add Atom updates to the crier event model) 312 | 313 | ## 11.24 314 | * Update content-api-models to 11.24 (scala 2.12.3) 315 | * This is the first version available for both scala `2.12.x` and scala `2.11.x` 316 | 317 | ## 11.23 318 | * Update content-api-models to 11.23 (add closeDate field to story questions atom) 319 | 320 | ## 11.22 321 | * Update content-api-models to 11.22 (add originating system in debug fields) 322 | 323 | ## 11.21 324 | * Update content-api-models to 11.21 (add email provider in story questions and update fezziwig) 325 | * Add StoriesQuery 326 | 327 | ## 11.19 328 | * Update content-api-models to 11.19 (rename qanda fields) 329 | 330 | ## 11.17 331 | * Update content-api-models to 11.17 (add shouldHideReaderRevenue field) 332 | 333 | ## 11.15 334 | * Update content-api-models to 11.16 (add Snippet models) 335 | 336 | ## 11.14 337 | * Update content-api-models to 11.14 (add StoriesResponse) 338 | 339 | ## 11.12 340 | * Update content-api-models to 11.12 (add EntitiesResponse model) 341 | 342 | ## 11.10 343 | * Update content-api-models to 11.9 (add block membership placeholder attribute) 344 | 345 | ## 11.9 346 | * Update content-api-models to 11.8 (organisation and place entity models) 347 | 348 | ## 11.7 349 | * Include show-stats parameter on search query. 350 | 351 | ## 11.6 352 | * Update content-api-models to 11.6 (adds categories + entityIds fields to Tag model) 353 | 354 | ## 11.5 355 | * Add storyquestions field to item response. 356 | 357 | ## 11.4 358 | * Provide support for storyquestions atoms. 359 | 360 | ## 11.3 361 | * Provide ability to query recipe, review and atom endpoints for internal clients. 362 | 363 | ## 11.2 364 | * Upgrade content-api-models dependency to 11.2 (upgrades circe to 0.7.0) 365 | 366 | ## 11.1 367 | * Upgrade content api models dependency to 11.1 to provide images on reviews model. 368 | 369 | ## 11.0 370 | * Upgrade content-api-models dependency to 11 (fezziwig dependency) 371 | 372 | ## 10.24 373 | * Upgrade content-api-models dependency to include sourceArticleId field in recipe and review atom types. 374 | 375 | ## 10.22 376 | * Upgrade content-api-models dependency (refactored circe macros) 377 | 378 | ## 10.21 379 | * Upgraded Circe to 0.6.1. 380 | 381 | ## 10.20 382 | * Recipe atom - adding `quantityRange` for ingredient and `images` (content-api-model 10.20) 383 | 384 | ## 10.19 385 | * Recipe atom - adding optional `unit` for `serves` (content-api-model 10.19) 386 | 387 | ## 10.18 388 | * thrift union macros (content-api-model 10.18) 389 | 390 | ## 10.17 391 | * `genre` field is now a list (content-api-model 10.17) 392 | 393 | ## 10.16 394 | * Support film review atoms (content-api-model 10.16) 395 | 396 | ## 10.15 397 | * Add `filename` query parameter. 398 | 399 | ## 10.4 400 | * Add high contrast sponsor logo 401 | 402 | ## 10.3 403 | * Add interactive atom 404 | 405 | ## 10.2 406 | * Add bodyText field 407 | 408 | ## 10.1 409 | * Add sponsor logo dimensions to Sponsorship model. 410 | 411 | ## 10.0 412 | * Remove JSON support. The client now receives only Thrift-encoded responses from the Content API. 413 | * Breaking change: Removed the optional `useThrift` parameter from the `GuardianContentClient` constructor. 414 | 415 | ## 9.5 416 | * Bump the content-api-models adding extra properties of media atoms: mime-type, duration and poster. 417 | 418 | ## 9.4 419 | * Bump the content-api-models dependency adding campaignColour and isMandatory 420 | 421 | ## 9.3 422 | * add `internalShortId` 423 | * add missing circe decoders 424 | 425 | ## 9.0 426 | * Bump the content-api-models dependency to a new major version. 427 | 428 | ## 8.12 429 | * Bump the content-api-models dependency 430 | 431 | ## 8.11 432 | * Add a new show-section parameter 433 | * Bump the content-api-models dependency 434 | 435 | ## 8.10 436 | * Bump the content-api-models dependency 437 | 438 | ## 8.9 439 | * Add `thumbnailImageUrl` to fields. 440 | * Improve JSON and Thrift deserialisation 441 | * Add podcasts categories 442 | 443 | ## 8.8 444 | * Bump the content-api-models dependency 445 | 446 | ## 8.7 447 | * Bump the content-api-models dependency 448 | * Downgrade libthrift from 0.9.3 to 0.9.1 449 | 450 | ## 8.6 451 | * Use content-api-models-json for JSON parsing 452 | 453 | ## 8.5 454 | * Revert the temporary fix for handling error responses when using Thrift 455 | * Update the models to add new optional fields to the `Blocks` model 456 | 457 | ## 8.4 458 | * Fix the error responses when using Thrift 459 | 460 | ## 8.3 461 | * Add Video Stats query type 462 | 463 | ## 8.2 464 | * Update Scrooge from 3.16.3 to 4.6.0 465 | * Split the models into a separate library. 466 | 467 | ## 8.1 468 | * Add `contains-element` filter. e.g. contains-element=video 469 | * Add `commentable` filter. 470 | * Thrift now supported by the Content API. 471 | 472 | ## 8.0 473 | * Add optional support (client-side) for processing thrift responses, together with json. 474 | However, thrift is NOT supported at the moment by Content Api; an upgrade of the Scala client will follow. 475 | * The following fields for item responses are now Scala Options: `results`, `relatedContent`, 476 | `editorsPicks`, `mostViewed`, and `leadContent`. 477 | 478 | ## 7.30 479 | * Add show-stats parameter. 480 | 481 | ## 7.29 482 | * Add star-rating and membership-access filters 483 | 484 | ## 7.28 485 | * Fix the `package` field on the content response. It is actually called `packages` and is a list. 486 | 487 | ## 7.27 488 | * Update content-atom version to 0.2.6. This adds `id` and `bucket` fields to the `quiz` model. 489 | * Update story-package version to 1.0.2. This adds the `packageName` field to the `package` model. 490 | * Add the `tweet` asset type. 491 | 492 | ## 7.26 493 | * Add tracking tag type 494 | 495 | ## 7.25 496 | * Add embed elements support 497 | 498 | ## 7.24 499 | * Add viewpoints support 500 | * Change quiz (single object) to quizzes (list of objects) 501 | 502 | ## 7.23 503 | * Add show-atoms filter 504 | * Add quiz support 505 | 506 | ## 7.22 507 | * Add show-packages filter 508 | 509 | ## 7.19 510 | * Add serializer for story package group 511 | * Add lang filter 512 | 513 | ## 7.18 514 | * Add "audio" content type 515 | * Add "lang" field to content 516 | * Add "pinned" block attribute 517 | 518 | ## 7.17 519 | * Add helper method to JsonParser 520 | 521 | ## 7.16 522 | * Add models for new story packages implementation 523 | 524 | ## 7.15 525 | * Split the model classes into a separate artifact called `content-api-models`. They are versioned in sync with the Scala client. 526 | 527 | ## 7.14 528 | 529 | DO NOT USE. This was a botched release. It was only released for Scala 2.11.x 530 | 531 | ## 7.13 532 | * Add `embed` to the list of asset types 533 | 534 | ## 7.12 535 | * Tweak model and fix deserialisation for crossword `separatorLocations` field 536 | 537 | ## 7.11 538 | * Added `crossword` content type 539 | 540 | ## 7.10 541 | * Parse "keyEvent" and "summary" boolean properties in block attributes 542 | 543 | ## 7.9 544 | * Added `clean` field to AssetFields 545 | * Added `sensitive` 546 | 547 | ## 7.8 548 | * Added `explicit` field to AssetFields 549 | * Added tests for audio elements 550 | * Added `durationMinutes`, `durationSeconds`, `explicit` and `clean` field to AudioElementField 551 | 552 | ## 7.7 553 | * Added the `embedType` and `html` fields to asset fields 554 | 555 | ## 7.6 556 | * Added the image field to Podcast metadata 557 | 558 | ## 7.5 559 | * Added thumbnailUrl, role, mediaId, iframeUrl, scriptName, scriptUrl, blockAds to AssetFields. 560 | * Added allowUgc to ContentFields. 561 | 562 | ## 7.4 563 | * Added AUDIO type to AssetType. 564 | 565 | ## 7.3 566 | * Added displayCredits field to AssetFields. 567 | * Added MaxSearchQueryIdSize to Limits. 568 | * Make userAgent protected. 569 | 570 | ## 7.2 571 | * Additional fields added to block elements. 572 | * 'durationMinutes' and 'durationSeconds' fields added to AssetFields. 573 | * 'legallySensitive' field added to ContentFields. 574 | * Travis file added. 575 | * Remove incorrectly added isMaster field. 576 | 577 | ## 7.1 578 | * Add thrift definition to built jar file. 579 | 580 | ## 7.0 581 | * Define and generate the Content API data model via Scrooge using Thrift. 582 | 583 | ## 6.10 584 | * Provide proper parsing of error responses. 585 | * Add support for the `type` field on `Content`, including a filter to search by content type. 586 | 587 | ## 6.9 588 | * Add an `isMaster` field to the ImageTypeData model. 589 | * Add a workaround for a bug in Dispatch that can cause a resource leak. 590 | 591 | ## 6.8 592 | * Include the client's version in the user-agent header sent with requests. 593 | * Make it easier to inject a custom implementation for the underlying HTTP client. 594 | 595 | ## 6.7 596 | * Add support for querying what content has been removed from the Content API. 597 | * Bump Scala to 2.11.7 598 | 599 | ## 6.6 600 | * Add crosswords. 601 | 602 | ## 6.5 603 | * Add audio and pull quote type data for block level elements. 604 | * Add additional field (credit) to type data of image block elements. 605 | 606 | ## 6.4 607 | * Update to dispatch v0.11.3, to avoid clashes when using Play v2.4.0 - see [#77](https://github.com/guardian/content-api-scala-client/pull/77) 608 | 609 | 610 | ## 6.3 611 | * Add type data for image block elements. 612 | * Add additional field (html) to type data of video block element. 613 | 614 | ## 6.2 615 | * Add type data for video, text and tweet block elements. 616 | 617 | ## 6.1 618 | * Add editions 619 | * Add block elements and date fields 620 | 621 | ## 6.0 622 | * Add rights in response 623 | * Remove collections 624 | 625 | ## 5.3 626 | * Add content blocks 627 | * Fix request headers not being sent correctly 628 | 629 | ## 5.2 630 | * Respect DNS short TTL by setting the connection lifetime maximum to 60 seconds. 631 | 632 | ## 5.1 633 | * Add email address field to tag 634 | 635 | ## 5.0 636 | * Do not allow partial item or collection queries that throw exceptions 637 | * Require ExecutionContext when running queries, not when constructing the client 638 | * Add decent toString method to queries 639 | 640 | ## 4.1 641 | * Expose generated URL to clients 642 | * Add a parameter for the maximum url size that Content API will accept 643 | 644 | ## 4.0 645 | * Large refactor to logically separate queries from the client itself. This makes queries themselves more reusable as 646 | they're now just datatypes. 647 | 648 | ## 3.7 649 | * Add twitter handle field to tag (only available for contributors tag) 650 | 651 | ## 3.6 652 | * Add new kicker fields on collections 653 | 654 | ## 3.5 655 | * Add back extended filtering on collections 656 | 657 | ## 3.4 658 | * Fix bug with filtering tags on tag pages 659 | 660 | ## 3.3 661 | * Allow http client to be overridden 662 | 663 | ## 3.2 664 | * Add back trait to ease extending 665 | 666 | ## 3.1 667 | * Use Json4s-ext for date parsing 668 | * Add productionOffice filter for content search 669 | * Add first name and last name to tag (only available for contributors tag) 670 | 671 | ## 3.0 672 | * Only provide an asynchronous future-based interface 673 | * Move to the `com.gu.contentapi.client` package 674 | * Remove various features no longer present in the API 675 | * Using an API key is no longer optional 676 | * Rename main client object to `GuardianContentClient` 677 | * Start a changelog 678 | * Other internal changes (eg. updating most of the tests) 679 | 680 | 681 | *(The history of previous versions has been lost to time.)* 682 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/BackoffStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | import com.gu.contentapi.client.Retry.RetryAttempt 6 | import com.gu.contentapi.client.model.HttpResponse 7 | import com.gu.contentapi.client.model.HttpResponse.isRecoverableHttpResponse 8 | 9 | import scala.concurrent.duration.Duration 10 | import scala.concurrent.{ExecutionContext, Future} 11 | 12 | case class ContentApiBackoffException(message: String) extends RuntimeException(message, null, false, false) 13 | 14 | abstract class BackoffStrategy extends Product with Serializable { self => 15 | 16 | def increment: BackoffStrategy = self match { 17 | // check max retries reached 18 | case Exponential(_, n, max) if n == max => RetryFailed(max) 19 | case Multiple(_, n, max, _) if n == max => RetryFailed(max) 20 | case Constant(_, n, max) if n == max => RetryFailed(max) 21 | // setup next delay cycle 22 | case Exponential(d, n, max) => 23 | val delay = if (n == 0) Duration(d.toMillis, TimeUnit.MILLISECONDS) else Duration(Math.pow(2, n) * d.toMillis, TimeUnit.MILLISECONDS) 24 | Exponential(delay, n + 1, max) 25 | case Multiple(d, n, max, f) => 26 | val delay = if (n == 0) Duration(d.toMillis, TimeUnit.MILLISECONDS) else Duration(f * d.toMillis, TimeUnit.MILLISECONDS) 27 | Multiple(delay, n + 1, max, f) 28 | case Constant(d, n, max) => 29 | Constant(d, n + 1, max) 30 | case x => x 31 | } 32 | } 33 | 34 | abstract class Retryable extends BackoffStrategy { 35 | val delay: Duration 36 | val attempts: Int 37 | val maxAttempts: Int 38 | } 39 | 40 | final case class Exponential private (delay: Duration, attempts: Int, maxAttempts: Int) extends Retryable 41 | final case class Multiple private (delay: Duration, attempts: Int, maxAttempts: Int, factor: Double) extends Retryable 42 | final case class Constant private (delay: Duration, attempts: Int, maxAttempts: Int) extends Retryable 43 | final case class RetryFailed private(attempts: Int) extends BackoffStrategy 44 | 45 | object BackoffStrategy { 46 | private val defaultMaxAttempts = 3 47 | private val defaultExponentialMinimumInterval = 100L 48 | private val defaultMinimumInterval = 250L 49 | private val defaultMinimumMultiplierFactor = 2.0 50 | 51 | def exponentialStrategy(delay: Duration, maxAttempts: Int): Exponential = exponential(delay, maxAttempts) 52 | def doublingStrategy(delay: Duration, maxAttempts: Int): Multiple = multiple(delay, maxAttempts, factor = 2.0) 53 | def multiplierStrategy(delay: Duration, maxAttempts: Int, multiplier: Double): Multiple = multiple(delay, maxAttempts, multiplier) 54 | def constantStrategy(delay: Duration, maxAttempts: Int): Constant = constant(delay, maxAttempts) 55 | 56 | private def exponential( 57 | min: Duration = Duration(defaultExponentialMinimumInterval, TimeUnit.MILLISECONDS), 58 | maxAttempts: Int = defaultMaxAttempts 59 | ): Exponential = { 60 | val ln = Math.max(min.toMillis, defaultExponentialMinimumInterval) 61 | val mx = if (maxAttempts > 0) maxAttempts else 1 62 | Exponential(Duration(ln, TimeUnit.MILLISECONDS), 0, mx) 63 | } 64 | 65 | private def multiple( 66 | min: Duration = Duration(defaultMinimumInterval, TimeUnit.MILLISECONDS), 67 | maxAttempts: Int = defaultMaxAttempts, 68 | factor: Double 69 | ): Multiple = { 70 | val ln = Math.max(min.toMillis, defaultMinimumInterval) 71 | val mx = if (maxAttempts > 0) maxAttempts else 1 72 | val fc = if (factor < defaultMinimumMultiplierFactor) defaultMinimumMultiplierFactor else factor 73 | Multiple(Duration(ln, TimeUnit.MILLISECONDS), 0, mx, fc) 74 | } 75 | 76 | private def constant( 77 | min: Duration = Duration(defaultMinimumInterval, TimeUnit.MILLISECONDS), 78 | maxAttempts: Int = defaultMaxAttempts 79 | ): Constant = { 80 | val ln = Math.max(min.toMillis, defaultMinimumInterval) 81 | val mx = if (maxAttempts > 0) maxAttempts else 1 82 | Constant(Duration(ln, TimeUnit.MILLISECONDS), 0, mx) 83 | } 84 | } 85 | 86 | object Retry { 87 | type RetryAttempt = Int 88 | def withRetry[A](backoffStrategy: BackoffStrategy, retryPredicate: A => Boolean)(operation: RetryAttempt => Future[A])(implicit executor: ScheduledExecutor, ec: ExecutionContext): Future[A] = { 89 | def loop(backoffStrategy: BackoffStrategy): Future[A] = backoffStrategy match { 90 | case r: Retryable => operation(r.attempts).flatMap { 91 | case result if retryPredicate(result) => executor.sleepFor(r.delay).flatMap(_ => loop(backoffStrategy.increment)) 92 | case result => Future.successful(result) 93 | } 94 | case RetryFailed(attempts) => 95 | Future.failed(ContentApiBackoffException(s"Backoff failed after $attempts attempts")) 96 | } 97 | loop(backoffStrategy) 98 | } 99 | } 100 | 101 | object HttpRetry { 102 | 103 | def withRetry(backoffStrategy: BackoffStrategy)(operation: RetryAttempt => Future[HttpResponse])(implicit executor: ScheduledExecutor, ec: ExecutionContext): Future[HttpResponse] = 104 | Retry.withRetry(backoffStrategy, isRecoverableHttpResponse)(operation) 105 | } -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/ContentApiClient.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.buildinfo.CapiBuildInfo 4 | import com.gu.contentapi.client.HttpRetry.withRetry 5 | import com.gu.contentapi.client.model.Direction.Next 6 | import com.gu.contentapi.client.model.HttpResponse.isSuccessHttpResponse 7 | import com.gu.contentapi.client.model._ 8 | import com.gu.contentapi.client.thrift.ThriftDeserializer 9 | import com.gu.contentatom.thrift.AtomType 10 | import com.twitter.scrooge.{ThriftStruct, ThriftStructCodec} 11 | 12 | import scala.concurrent.{ExecutionContext, Future} 13 | 14 | trait ContentApiClient { 15 | 16 | /** Your API key */ 17 | def apiKey: String 18 | 19 | /** The user-agent identifier */ 20 | def userAgent: String = "content-api-scala-client/"+CapiBuildInfo.version 21 | 22 | /** The url of the CAPI endpoint */ 23 | def targetUrl: String = "https://content.guardianapis.com" 24 | 25 | /** Queries CAPI. 26 | * 27 | * This method must make a GET request to the CAPI endpoint 28 | * and streamline the response into an HttpResponse object. 29 | * 30 | * It is a design decision that this method is virtual. 31 | * Any implementation would have to rely on a specific 32 | * technology stack, e.g. an HTTP client. Fundamentally, 33 | * the responsibility of making these implementation 34 | * choices should be pushed out to the end of the world. 35 | * 36 | * @param url The CAPI REST url 37 | * @param headers Custom HTTP parameters 38 | * @return an HttpResponse holding the response in the form of an array of bytes 39 | */ 40 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] 41 | 42 | /** Some HTTP headers sent along each CAPI request */ 43 | private val headers = 44 | Map("User-Agent" -> userAgent, "Accept" -> "application/x-thrift", "Accept-Language" -> "*") 45 | 46 | /** Authentication and format parameters appended to each query */ 47 | private def parameters = Map("api-key" -> apiKey, "format" -> "thrift") 48 | 49 | /** Streamlines the handling of a valid CAPI response */ 50 | 51 | private def fetchResponse(contentApiQuery: ContentApiQuery[_])(implicit ec: ExecutionContext): Future[Array[Byte]] = get(url(contentApiQuery), headers).flatMap { 52 | case response if isSuccessHttpResponse(response) => Future.successful(response) 53 | case response => Future.failed(ContentApiError(response)) 54 | }.map(_.body) 55 | 56 | private def unfoldM[A, B](f: B => (A, Option[Future[B]]))(fb: Future[B])(implicit ec: ExecutionContext): Future[List[A]] = 57 | fb.flatMap { b => 58 | f(b) match { 59 | case (a, None) => Future.successful(a :: Nil) 60 | case (a, Some(b)) => unfoldM(f)(b).map(a :: _) 61 | } 62 | } 63 | 64 | def url(contentApiQuery: ContentApiQuery[_]): String = 65 | contentApiQuery.getUrl(targetUrl, parameters) 66 | 67 | /** Runs the query against the Content API. 68 | * 69 | * @tparam Q the type of a Content API query 70 | * @param query the query 71 | * @return a future resolving to an unmarshalled response 72 | */ 73 | def getResponse[R <: ThriftStruct](query: ContentApiQuery[R])( 74 | implicit 75 | decoder: Decoder[R], 76 | context: ExecutionContext): Future[R] = 77 | fetchResponse(query) map decoder.decode 78 | 79 | /** Unfolds a query to its results, page by page 80 | * 81 | * @param query the initial query 82 | * @tparam R the response type expected for this query 83 | * @tparam E the 'element' type for the list of elements returned in the response - eg 'Tag' for 'TagsResponse' 84 | * @tparam M a type specified by the caller to summarise the results of each response. Eg, might be `Seq[E]` 85 | * @param f a result-processing function that converts the standard response type to the `M` type 86 | * @return a future of a list of result-processed results (eg, if `M` = `Seq[E]`, the final result is `List[Seq[E]]`) 87 | */ 88 | // `R : Decoder` is a Scala 'context-bound', and means "To compile I need an implicit instance of `Decoder[R]`!" 89 | def paginate[R <: ThriftStruct: Decoder, E, M](query: PaginatedApiQuery[R, E])(f: R => M)( 90 | implicit 91 | context: ExecutionContext 92 | ): Future[List[M]] = 93 | unfoldM { r: R => 94 | (f(r), query.followingQueryGiven(r, Next).map(getResponse(_))) 95 | }(getResponse(query)) 96 | 97 | /** Unfolds a query by accumulating its results - each response is transformed (by function `f`) and then combined 98 | * (with function `g`) into a single accumulated result object. 99 | * 100 | * @param query the initial query 101 | * @tparam R the response type expected for this query 102 | * @tparam E the 'element' type for the list of elements returned in the response - eg 'Tag' for 'TagsResponse' 103 | * @tparam M a type specified by the caller to summarise the results of each response. Eg, might be `Seq[E]` 104 | * @param f a result-processing function that converts the standard response type to the `M` type 105 | * @param g a function that squashes together ('reduces') two `M` types - eg concatenates two `Seq[E]` 106 | * @return a future of the accumulated value 107 | */ 108 | def paginateAccum[R <: ThriftStruct: Decoder, E, M](query: PaginatedApiQuery[R, E])(f: R => M, g: (M, M) => M)( 109 | implicit 110 | context: ExecutionContext 111 | ): Future[M] = 112 | paginate(query)(f).map { 113 | case Nil => throw new RuntimeException("Something went wrong with the query") 114 | case m :: Nil => m 115 | case ms => ms.reduce(g) 116 | } 117 | 118 | /** Unfolds a query by accumulating its results - each response is transformed and added to an accumulator value 119 | * by a single folding function `f`. 120 | * 121 | * @param query the initial query 122 | * @tparam R the response type expected for this query 123 | * @tparam E the 'element' type for the list of elements returned in the response - eg 'Tag' for 'TagsResponse' 124 | * @tparam M a type specified by the caller to summarise the results the responses. Eg, might be `Int` 125 | * @param m an initial 'empty' starting value to begin the accumulation with. Eg, might be `0` 126 | * @param f a result-processing function that adds the result of a response to the summary value accumulated so far 127 | * @return a future of the accumulated value 128 | */ 129 | def paginateFold[R <: ThriftStruct: Decoder, E, M](query: PaginatedApiQuery[R, E])(m: M)(f: (R, M) => M)( 130 | implicit 131 | context: ExecutionContext 132 | ): Future[M] = { 133 | def paginateFoldIn(currentQuery: Option[PaginatedApiQuery[R, E]])(m: M): Future[M] = { 134 | val req = currentQuery.map(getResponse(_)).getOrElse(getResponse(query)) 135 | req.flatMap { r: R => 136 | query.followingQueryGiven(r, Next) match { 137 | case None => Future.successful(f(r, m)) 138 | case Some(nextQuery) => paginateFoldIn(Some(nextQuery))(f(r, m)) 139 | } 140 | } 141 | } 142 | 143 | paginateFoldIn(None)(m) 144 | } 145 | } 146 | 147 | trait RetryableContentApiClient extends ContentApiClient { 148 | def backoffStrategy: BackoffStrategy 149 | implicit def executor: ScheduledExecutor 150 | 151 | abstract override def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = withRetry(backoffStrategy){ retryAttempt => 152 | super.get(url, headers + ("Request-Attempt" -> s"$retryAttempt")) 153 | } 154 | } 155 | 156 | 157 | object ContentApiClient extends ContentApiQueries 158 | 159 | /** Utility functions to instantiate each type of query */ 160 | trait ContentApiQueries { 161 | def item(id: String) = ItemQuery(id) 162 | val search = SearchQuery() 163 | val tags = TagsQuery() 164 | val sections = SectionsQuery() 165 | val editions = EditionsQuery() 166 | val atoms = AtomsQuery() 167 | def atomUsage(atomType: AtomType, atomId: String) = AtomUsageQuery(atomType, atomId) 168 | @deprecated("Recipe atoms no longer exist and should not be relied upon. No data will be returned and this query will be removed in a future iteration of the library") 169 | val recipes = RecipesQuery() 170 | val reviews = ReviewsQuery() 171 | val gameReviews = GameReviewsQuery() 172 | val restaurantReviews = RestaurantReviewsQuery() 173 | val filmReviews = FilmReviewsQuery() 174 | val videoStats = VideoStatsQuery() 175 | } -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/Parameter.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | trait Parameter { 4 | 5 | type Self 6 | type ParameterOwner <: Parameters[ParameterOwner] 7 | 8 | def owner: ParameterOwner 9 | def name: String 10 | def value: Option[Self] 11 | 12 | def asTuple = value.map(name -> _) 13 | 14 | def withValue(newValue: Option[Self]): Parameter 15 | 16 | def apply(newValue: Self): ParameterOwner = apply(Some(newValue)) 17 | 18 | def apply(newValue: Option[Self]): ParameterOwner = owner.withParameter(this.withValue(newValue)) 19 | 20 | def reset(): ParameterOwner = owner.withParameters(Map.empty) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/Parameters.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.time.Instant 4 | 5 | trait Parameters[Owner <: Parameters[Owner]] { self: Owner => 6 | def stringParam(key: String, value: String): Owner = { 7 | withParameter(StringParameter(key, Some(value))) 8 | } 9 | 10 | def intParam(key: String, value: Int): Owner = { 11 | withParameter(IntParameter(key, Some(value))) 12 | } 13 | 14 | def boolParam(key: String, value: Boolean): Owner = { 15 | withParameter(BoolParameter(key, Some(value))) 16 | } 17 | 18 | def dateParam(key: String, value: Instant): Owner = { 19 | withParameter(DateParameter(key, Some(value))) 20 | } 21 | 22 | def withParameter(parameter: Parameter): Owner = { 23 | withParameters(parameterHolder.updated(parameter.name, parameter)) 24 | } 25 | 26 | def withParameters(parameterMap: Map[String, Parameter]): Owner 27 | 28 | def has(param: String) = parameterHolder.contains(param) 29 | 30 | protected def parameterHolder: Map[String, Parameter] 31 | 32 | def parameters: Map[String, String] = 33 | parameterHolder.mapValues(_.value).toMap.collect { case (k, Some(v)) => (k, v.toString) } 34 | 35 | protected trait OwnedParameter extends Parameter { 36 | type ParameterOwner = Owner 37 | def owner = self 38 | } 39 | 40 | case class StringParameter(name: String, value: Option[String] = None) extends OwnedParameter { 41 | type Self = String 42 | def withValue(newValue: Option[String]) = copy(value = newValue) 43 | def setIfUndefined(str: String) = if (owner.has(name)) owner else apply(str) 44 | } 45 | 46 | case class IntParameter(name: String, value: Option[Int] = None) extends OwnedParameter { 47 | type Self = Int 48 | def withValue(newValue: Option[Int]) = copy(value = newValue) 49 | def setIfUndefined(v: Int) = if (owner.has(name)) owner else apply(v) 50 | } 51 | 52 | case class DateParameter(name: String, value: Option[Instant] = None) extends OwnedParameter { 53 | type Self = Instant 54 | def withValue(newValue: Option[Instant]) = copy(value = newValue) 55 | } 56 | 57 | case class BoolParameter(name: String, value: Option[Boolean] = None) extends OwnedParameter { 58 | type Self = Boolean 59 | def withValue(newValue: Option[Boolean]) = copy(value = newValue) 60 | def apply(): Owner = apply(true) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/ScheduledExecutor.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.util.concurrent.{Executors, RejectedExecutionException, ScheduledExecutorService} 4 | 5 | import scala.concurrent.{Future, Promise} 6 | import scala.concurrent.duration.Duration 7 | import scala.language.implicitConversions 8 | 9 | object ScheduledExecutor { 10 | def apply(): ScheduledExecutor = { 11 | new ScheduledExecutor { 12 | private lazy val underlying: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() 13 | override def sleepFor(napTime: Duration): Future[Unit] = { 14 | val promise = Promise[Unit]() 15 | val runnable = new Runnable { 16 | override def run(): Unit = promise.success(()) 17 | } 18 | underlying.schedule(runnable, napTime.length, napTime.unit) 19 | promise.future 20 | } 21 | } 22 | } 23 | } 24 | 25 | /** 26 | * A single threaded executor for tasks scheduled in the future 27 | * @throws IllegalArgumentException if { @code corePoolSize < 0} 28 | * @throws NullPointerException if { @code threadFactory} or 29 | * { @code handler} is null 30 | */ 31 | abstract class ScheduledExecutor { 32 | 33 | /** 34 | * Creates a Future and schedules the operation to run after the given delay. 35 | * 36 | * @param napTime duration for which to delay execution 37 | * @return a Future to capture the signal that napTime is over 38 | * @throws RejectedExecutionException if the task cannot be 39 | * scheduled for execution 40 | */ 41 | 42 | def sleepFor(napTime: Duration): Future[Unit] 43 | 44 | } 45 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/model/ContentApiError.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | import com.gu.contentapi.client.model.v1.ErrorResponse 4 | import com.gu.contentapi.client.thrift.ThriftDeserializer 5 | import scala.util.Try 6 | 7 | case class ContentApiError(httpStatus: Int, httpMessage: String, errorResponse: Option[ErrorResponse] = None) extends RuntimeException(httpMessage) 8 | 9 | object ContentApiError { 10 | def apply(response: HttpResponse): ContentApiError = { 11 | val errorResponse = Try(ThriftDeserializer.deserialize(response.body, ErrorResponse)).toOption 12 | ContentApiError(response.statusCode, response.statusMessage, errorResponse) 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/model/Decoder.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.model.v1._ 4 | import com.gu.contentapi.client.thrift.ThriftDeserializer 5 | import com.twitter.scrooge.{ThriftStruct, ThriftStructCodec} 6 | 7 | 8 | class Decoder[Response <: ThriftStruct](codec: ThriftStructCodec[Response]) { 9 | def decode(data: Array[Byte]): Response = ThriftDeserializer.deserialize(data, codec) 10 | } 11 | 12 | trait PaginationDecoder[Response, Element] { 13 | val pageSize: Response => Int 14 | val elements: Response => collection.Seq[Element] 15 | } 16 | 17 | object Decoder { 18 | type PageableResponseDecoder[Response <: ThriftStruct, Element] = Decoder[Response] with PaginationDecoder[Response, Element] 19 | 20 | def pageableResponseDecoder[R <: ThriftStruct, E](c: ThriftStructCodec[R])(ps: R => Int, el: R => collection.Seq[E]): PageableResponseDecoder[R, E] = 21 | new Decoder[R](c) with PaginationDecoder[R, E] { 22 | val pageSize: R => Int = ps 23 | val elements: R => collection.Seq[E] = el 24 | } 25 | 26 | implicit val itemDecoder: Decoder[ItemResponse] = new Decoder(ItemResponse) 27 | implicit val tagsDecoder: PageableResponseDecoder[TagsResponse, Tag] = pageableResponseDecoder(TagsResponse)(_.pageSize, _.results) 28 | implicit val sectionsQuery: Decoder[SectionsResponse] = new Decoder(SectionsResponse) 29 | implicit val editionsDecoder: Decoder[EditionsResponse] = new Decoder(EditionsResponse) 30 | implicit val videoStatsDecoder: Decoder[VideoStatsResponse] = new Decoder(VideoStatsResponse) 31 | implicit val atomsDecoder: Decoder[AtomsResponse] = new Decoder(AtomsResponse) 32 | implicit val searchDecoder: PageableResponseDecoder[SearchResponse, Content] = pageableResponseDecoder(SearchResponse)(_.pageSize, _.results) 33 | implicit val atomUsageDecoder: Decoder[AtomUsageResponse] = new Decoder(AtomUsageResponse) 34 | } 35 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/model/HttpResponse.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | case class HttpResponse(body: Array[Byte], statusCode: Int, statusMessage: String) 4 | 5 | object HttpResponse { 6 | val failedButMaybeRecoverableCodes = Set(408, 429, 504, 509) 7 | val successfulResponseCodes = Set(200, 302) 8 | 9 | def isSuccessHttpResponse(httpResponse: HttpResponse) = successfulResponseCodes.contains(httpResponse.statusCode) 10 | 11 | def isRecoverableHttpResponse(httpResponse: HttpResponse) = failedButMaybeRecoverableCodes.contains(httpResponse.statusCode) 12 | 13 | } -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/model/Queries.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | import com.gu.contentapi.client.Decoder.PageableResponseDecoder 4 | import com.gu.contentapi.client.model.Direction.Next 5 | import com.gu.contentapi.client.model.v1.{AtomUsageResponse, AtomsResponse, Content, EditionsResponse, ItemResponse, SearchResponse, SectionsResponse, Tag, TagsResponse, VideoStatsResponse} 6 | import com.gu.contentapi.client.utils.QueryStringParams 7 | import com.gu.contentapi.client.{Parameter, Parameters} 8 | import com.gu.contentatom.thrift.AtomType 9 | import com.twitter.scrooge.ThriftStruct 10 | 11 | sealed trait ContentApiQuery[+Response <: ThriftStruct] { 12 | def parameters: Map[String, String] 13 | 14 | def pathSegment: String 15 | 16 | override def toString = s"""${getClass.getSimpleName}(${getUrl("")})""" 17 | 18 | private def url(location: String, parameters: Map[String, String]): String = { 19 | require(!location.contains('?'), "must not specify parameters in URL") 20 | 21 | location + QueryStringParams(parameters) 22 | } 23 | 24 | def getUrl(targetUrl: String, customParameters: Map[String, String] = Map.empty): String = 25 | url(s"$targetUrl/$pathSegment", parameters ++ customParameters) 26 | 27 | } 28 | 29 | abstract class PaginatedApiQuery[Response <: ThriftStruct, Element]( 30 | implicit prd: PageableResponseDecoder[Response, Element] 31 | ) extends ContentApiQuery[Response] { 32 | 33 | /** 34 | * Produce a version of this query that explicitly sets previously ''unset'' pagination/ordering parameters, 35 | * matching how the Content API server decided to process the previous request. 36 | * 37 | * For instance, if the Content API decided to process https://content.guardianapis.com/search?q=brexit 38 | * with pageSize:10 & orderBy:relevance, that will have been detailed in the CAPI response - and therefore we 39 | * can copy those parameters into our following query so we don't change how we're ordering the results 40 | * as we paginate through them! 41 | */ 42 | def setPaginationConsistentWith(response: Response): PaginatedApiQuery[Response, Element] 43 | 44 | def followingQueryGiven(response: Response, direction: Direction): Option[PaginatedApiQuery[Response, Element]] = 45 | if (response.impliesNoFurtherResults) None else setPaginationConsistentWith(response).followingQueryGivenFull(response, direction) 46 | 47 | /** Construct a query for the subsequent results after this response. This method will only be called if the 48 | * response was supplied a full page of results, meaning that there's the possibility of more results to fetch. 49 | */ 50 | protected def followingQueryGivenFull(response: Response, direction: Direction): Option[PaginatedApiQuery[Response, Element]] 51 | } 52 | 53 | trait SearchQueryBase[Self <: SearchQueryBase[Self]] 54 | extends ContentApiQuery[SearchResponse] 55 | with ShowParameters[Self] 56 | with ShowReferencesParameters[Self] 57 | with OrderByParameter[Self] 58 | with UseDateParameter[Self] 59 | with PaginationParameters[Self] 60 | with FilterParameters[Self] 61 | with FilterExtendedParameters[Self] 62 | with FilterSearchParameters[Self] { 63 | this: Self => 64 | } 65 | 66 | case class ItemQuery(id: String, parameterHolder: Map[String, Parameter] = Map.empty, channelId: Option[String]=None) 67 | extends ContentApiQuery[ItemResponse] 68 | with EditionParameters[ItemQuery] 69 | with ShowParameters[ItemQuery] 70 | with ShowReferencesParameters[ItemQuery] 71 | with ShowExtendedParameters[ItemQuery] 72 | with PaginationParameters[ItemQuery] 73 | with OrderByParameter[ItemQuery] 74 | with UseDateParameter[ItemQuery] 75 | with FilterParameters[ItemQuery] 76 | with FilterExtendedParameters[ItemQuery] 77 | with FilterSearchParameters[ItemQuery] { 78 | 79 | def withParameters(parameterMap: Map[String, Parameter]) = copy(id, parameterMap, channelId) 80 | 81 | def withChannelId(newChannel:String) = copy(id, parameterHolder, Some(newChannel)) 82 | 83 | def withoutChannelId() = copy(id, parameterHolder, None) 84 | 85 | def itemId(contentId: String): ItemQuery = 86 | copy(id = contentId) 87 | 88 | override def pathSegment: String = channelId match { 89 | case None => id 90 | case Some(chl) => s"channel/$chl/item/$id" 91 | } 92 | } 93 | 94 | case class SearchQuery(parameterHolder: Map[String, Parameter] = Map.empty, channelId: Option[String] = None) 95 | extends PaginatedApiQuery[SearchResponse, Content] with SearchQueryBase[SearchQuery] { 96 | 97 | def setPaginationConsistentWith(response: SearchResponse): PaginatedApiQuery[SearchResponse, Content] = 98 | pageSize.setIfUndefined(response.pageSize).orderBy.setIfUndefined(response.orderBy) 99 | 100 | def withParameters(parameterMap: Map[String, Parameter]): SearchQuery = copy(parameterMap, channelId) 101 | 102 | /** 103 | * Make this search on a CAPI channel rather than against web-only content 104 | * For more information about channels, and the reason why your app should only be in one channel, 105 | * contact the Content API team 106 | * @param channelId the channel to search against, or "all" to search across all channels. 107 | */ 108 | def withChannel(channelId:String):SearchQuery = copy(parameterHolder, Some(channelId)) 109 | 110 | def withoutChannel(): SearchQuery = copy(parameterHolder, None) 111 | 112 | override def pathSegment: String = channelId match { 113 | case None=>"search" 114 | case Some(chnl)=>s"channel/$chnl/search" 115 | } 116 | 117 | protected override def followingQueryGivenFull(response: SearchResponse, direction: Direction) = for { 118 | lastResultInResponse <- response.results.lastOption 119 | } yield FollowingSearchQuery(this, lastResultInResponse.id, direction) 120 | 121 | } 122 | 123 | case class TagsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 124 | extends PaginatedApiQuery[TagsResponse, Tag] 125 | with ShowReferencesParameters[TagsQuery] 126 | with PaginationParameters[TagsQuery] 127 | with FilterParameters[TagsQuery] 128 | with FilterTagParameters[TagsQuery] 129 | with FilterSearchParameters[TagsQuery] { 130 | 131 | def setPaginationConsistentWith(response: TagsResponse): PaginatedApiQuery[TagsResponse, Tag] = 132 | pageSize.setIfUndefined(response.pageSize) 133 | 134 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 135 | 136 | override def pathSegment: String = "tags" 137 | 138 | protected override def followingQueryGivenFull(response: TagsResponse, direction: Direction): Option[TagsQuery] = { 139 | val followingPage = response.currentPage + direction.delta 140 | if (followingPage >= 1 && followingPage <= response.pages) Some(page(followingPage)) else None 141 | } 142 | } 143 | 144 | case class SectionsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 145 | extends ContentApiQuery[SectionsResponse] 146 | with FilterSearchParameters[SectionsQuery] 147 | with FilterSectionParameters[SectionsQuery]{ 148 | 149 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 150 | 151 | override def pathSegment: String = "sections" 152 | } 153 | 154 | case class EditionsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 155 | extends ContentApiQuery[EditionsResponse] 156 | with FilterSearchParameters[EditionsQuery] { 157 | 158 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 159 | 160 | override def pathSegment: String = "editions" 161 | } 162 | 163 | case class VideoStatsQuery( 164 | edition: Option[String] = None, 165 | section: Option[String] = None, 166 | parameterHolder: Map[String, Parameter] = Map.empty) 167 | extends ContentApiQuery[VideoStatsResponse] 168 | with FilterSearchParameters[VideoStatsQuery] { 169 | 170 | def withParameters(parameterMap: Map[String, Parameter]) = copy(edition, section, parameterMap) 171 | 172 | override def pathSegment: String = Seq(Some("stats/videos"), edition, section).flatten.mkString("/") 173 | } 174 | 175 | case class AtomsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 176 | extends ContentApiQuery[AtomsResponse] 177 | with AtomsParameters[AtomsQuery] 178 | with PaginationParameters[AtomsQuery] 179 | with UseDateParameter[AtomsQuery] 180 | with OrderByParameter[AtomsQuery] 181 | with FilterSearchParameters[AtomsQuery] { 182 | 183 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 184 | 185 | override def pathSegment: String = "atoms" 186 | } 187 | 188 | case class AtomUsageQuery(atomType: AtomType, atomId: String, parameterHolder: Map[String, Parameter] = Map.empty) 189 | extends ContentApiQuery[AtomUsageResponse] 190 | with PaginationParameters[AtomUsageQuery] { 191 | 192 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterHolder = parameterMap) 193 | 194 | override def pathSegment: String = s"atom/${atomType.toString.toLowerCase}/$atomId/usage" 195 | } 196 | 197 | @deprecated("Recipe atoms no longer exist and should not be relied upon. No data will be returned and this class will be removed in a future iteration of the library") 198 | case class RecipesQuery(parameterHolder: Map[String, Parameter] = Map.empty) 199 | extends ContentApiQuery[AtomsResponse] 200 | with PaginationParameters[RecipesQuery] 201 | with RecipeParameters[RecipesQuery] { 202 | 203 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 204 | 205 | override def pathSegment: String = "atoms/recipes" 206 | } 207 | 208 | case class ReviewsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 209 | extends ContentApiQuery[AtomsResponse] 210 | with PaginationParameters[ReviewsQuery] 211 | with ReviewSpecificParameters[ReviewsQuery] { 212 | 213 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 214 | 215 | override def pathSegment: String = "atoms/reviews" 216 | } 217 | 218 | case class GameReviewsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 219 | extends ContentApiQuery[AtomsResponse] 220 | with ReviewSpecificParameters[GameReviewsQuery] 221 | with PaginationParameters[GameReviewsQuery] 222 | with GameParameters[GameReviewsQuery] { 223 | 224 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 225 | 226 | override def pathSegment: String = "atoms/reviews/game" 227 | } 228 | 229 | case class RestaurantReviewsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 230 | extends ContentApiQuery[AtomsResponse] 231 | with ReviewSpecificParameters[RestaurantReviewsQuery] 232 | with PaginationParameters[RestaurantReviewsQuery] 233 | with RestaurantParameters[RestaurantReviewsQuery] { 234 | 235 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 236 | 237 | override def pathSegment: String = "atoms/reviews/restaurant" 238 | } 239 | 240 | case class FilmReviewsQuery(parameterHolder: Map[String, Parameter] = Map.empty) 241 | extends ContentApiQuery[AtomsResponse] 242 | with ReviewSpecificParameters[FilmReviewsQuery] 243 | with PaginationParameters[FilmReviewsQuery] 244 | with FilmParameters[FilmReviewsQuery] { 245 | 246 | def withParameters(parameterMap: Map[String, Parameter]) = copy(parameterMap) 247 | 248 | override def pathSegment: String = "atoms/reviews/film" 249 | } 250 | 251 | sealed trait Direction { 252 | val pathSegment: String 253 | val delta: Int 254 | def guidingElementIn[T](elements: Iterable[T]): Option[T] 255 | } 256 | 257 | object Direction { 258 | object Next extends Direction { 259 | override val pathSegment: String = "next" 260 | override val delta: Int = 1 261 | override def guidingElementIn[T](elements: Iterable[T]): Option[T] = elements.lastOption 262 | 263 | } 264 | object Previous extends Direction { 265 | override val pathSegment: String = "prev" 266 | override val delta: Int = -1 267 | override def guidingElementIn[T](elements: Iterable[T]): Option[T] = elements.headOption 268 | } 269 | 270 | def forPathSegment(pathSegment: String): Direction = pathSegment match { 271 | case Next.pathSegment => Next 272 | case Previous.pathSegment => Previous 273 | } 274 | } 275 | 276 | case class FollowingSearchQuery( 277 | originalQuery: PaginatedApiQuery[SearchResponse, Content], contentId: String, direction: Direction = Next 278 | ) extends PaginatedApiQuery[SearchResponse, Content] { 279 | 280 | def parameters: Map[String, String] = originalQuery.parameters.filterKeys(not(isPaginationParameter)).toMap 281 | 282 | override def pathSegment: String = s"content/$contentId/${direction.pathSegment}" 283 | 284 | override def setPaginationConsistentWith(response: SearchResponse): PaginatedApiQuery[SearchResponse, Content] = 285 | originalQuery.setPaginationConsistentWith(response) 286 | 287 | protected override def followingQueryGivenFull(response: SearchResponse, updatedDirection: Direction): Option[PaginatedApiQuery[SearchResponse, Content]] = for { 288 | content <- updatedDirection.guidingElementIn(response.results) 289 | } yield copy(contentId = content.id, direction = updatedDirection) 290 | 291 | } 292 | 293 | trait EditionParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 294 | def edition = StringParameter("edition") 295 | } 296 | 297 | trait ShowParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 298 | def showFields = StringParameter("show-fields") 299 | def showTags = StringParameter("show-tags") 300 | def showElements = StringParameter("show-elements") 301 | def showRights = StringParameter("show-rights") 302 | def showBlocks = StringParameter("show-blocks") 303 | def showAtoms = StringParameter("show-atoms") 304 | def showSection = BoolParameter("show-section") 305 | def showStats = BoolParameter("show-stats") 306 | def showAliasPaths = BoolParameter("show-alias-paths") 307 | def showSchemaOrg = BoolParameter("show-schemaorg") 308 | def showChannels = StringParameter("show-channels") 309 | } 310 | 311 | trait ShowReferencesParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 312 | def showReferences = StringParameter("show-references") 313 | } 314 | 315 | trait ShowExtendedParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 316 | def showStoryPackage = BoolParameter("show-story-package") 317 | def showRelated = BoolParameter("show-related") 318 | def showMostViewed = BoolParameter("show-most-viewed") 319 | def showEditorsPicks = BoolParameter("show-editors-picks") 320 | def showPackages = BoolParameter("show-packages") 321 | } 322 | 323 | trait PaginationParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 324 | def page = IntParameter("page") 325 | def pageSize = IntParameter("page-size") 326 | } 327 | 328 | trait OrderByParameter[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 329 | def orderBy = StringParameter("order-by") 330 | } 331 | 332 | trait UseDateParameter[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 333 | def useDate = StringParameter("use-date") 334 | } 335 | 336 | trait FilterParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 337 | def section = StringParameter("section") 338 | def reference = StringParameter("reference") 339 | def referenceType = StringParameter("reference-type") 340 | def productionOffice = StringParameter("production-office") 341 | } 342 | 343 | trait FilterExtendedParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 344 | def tag = StringParameter("tag") 345 | def ids = StringParameter("ids") 346 | def paths = StringParameter("paths") 347 | def rights = StringParameter("rights") 348 | def leadContent = StringParameter("lead-content") 349 | def fromDate = DateParameter("from-date") 350 | def toDate = DateParameter("to-date") 351 | def contentType = StringParameter("type") 352 | def lang = StringParameter("lang") 353 | def starRating = IntParameter("star-rating") 354 | def membershipAccess = StringParameter("membership-access") 355 | def containsElement = StringParameter("contains-element") 356 | def commentable = BoolParameter("commentable") 357 | def filename = StringParameter("filename") 358 | } 359 | 360 | trait FilterTagParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 361 | def tagType = StringParameter("type") 362 | def sponsorshipType = StringParameter("sponsorship-type") 363 | } 364 | 365 | trait FilterSectionParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 366 | def sponsorshipType = StringParameter("sponsorship-type") 367 | } 368 | 369 | trait FilterSearchParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 370 | def q = StringParameter("q") 371 | def queryFields = StringParameter("query-fields") 372 | } 373 | 374 | trait AtomsParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 375 | def types = StringParameter("types") 376 | def searchFields = StringParameter("searchFields") 377 | def fromDate = DateParameter("from-date") 378 | def toDate = DateParameter("to-date") 379 | } 380 | 381 | trait RecipeParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 382 | def title = StringParameter("title") 383 | def credits = StringParameter("credits") 384 | def categories = StringParameter("category") 385 | def cuisines = StringParameter("cuisine") 386 | def dietary = StringParameter("dietary") 387 | def celebration = StringParameter("celebration") 388 | def ingredients = StringParameter("ingredients") 389 | def maxTime = IntParameter("max-time") 390 | } 391 | 392 | trait ReviewSpecificParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 393 | def reviewer = StringParameter("reviewer") 394 | def maxRating = IntParameter("max-rating") 395 | def minRating = IntParameter("min-rating") 396 | } 397 | 398 | trait FilmParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 399 | def name = StringParameter("name") 400 | def genres = StringParameter("genres") 401 | def actors = StringParameter("actors") 402 | def directors = StringParameter("directors") 403 | } 404 | 405 | trait GameParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 406 | def gameName = StringParameter("name") 407 | } 408 | 409 | trait RestaurantParameters[Owner <: Parameters[Owner]] extends Parameters[Owner] { this: Owner => 410 | def restaurantName = StringParameter("name") 411 | } 412 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/model/package.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.Decoder.PageableResponseDecoder 4 | import com.twitter.scrooge.ThriftStruct 5 | 6 | package object model { 7 | 8 | implicit class RichPageableResponse[R <: ThriftStruct, E](response: R)(implicit prd: PageableResponseDecoder[R, E]) { 9 | 10 | val impliesNoFurtherResults: Boolean = prd.elements(response).size < prd.pageSize(response) 11 | } 12 | 13 | private[model] def not[A](f: A => Boolean): A => Boolean = !f(_) 14 | 15 | private[model] val isPaginationParameter = Set("page") 16 | } -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/thrift/ThriftDeserializer.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.thrift 2 | 3 | import java.io.ByteArrayInputStream 4 | 5 | import com.twitter.scrooge.{ThriftStruct, ThriftStructCodec} 6 | import org.apache.thrift.protocol.TCompactProtocol 7 | import org.apache.thrift.transport.TIOStreamTransport 8 | 9 | object ThriftDeserializer { 10 | 11 | def deserialize[T <: ThriftStruct](responseBody: Array[Byte], codec: ThriftStructCodec[T]): T = { 12 | val bbis = new ByteArrayInputStream(responseBody) 13 | val transport = new TIOStreamTransport(bbis) 14 | val protocol = new TCompactProtocol(transport) 15 | codec.decode(protocol) 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/CapiModelEnrichment.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils 2 | 3 | import com.gu.contentapi.client.model.v1._ 4 | import com.gu.contentapi.client.utils.format._ 5 | 6 | import org.apache.commons.codec.digest.DigestUtils 7 | 8 | import java.time.OffsetDateTime 9 | import java.time.format.DateTimeFormatter 10 | import java.time.ZonedDateTime 11 | import java.time.ZoneOffset 12 | 13 | object CapiModelEnrichment { 14 | 15 | type ContentFilter = Content => Boolean 16 | 17 | def getFromPredicate[T](content: Content, predicates: List[(ContentFilter, T)]): Option[T] = 18 | predicates.collectFirst { case (predicate, t) if predicate(content) => t } 19 | 20 | def tagExistsWithId(tagId: String): ContentFilter = content => content.tags.exists(tag => tag.id == tagId) 21 | 22 | def displayHintExistsWithName(displayHintName: String): ContentFilter = content => content.fields.flatMap(_.displayHint).contains(displayHintName) 23 | 24 | def isLiveBloggingNow: ContentFilter = content => content.fields.flatMap(_.liveBloggingNow).contains(true) 25 | 26 | val isImmersive: ContentFilter = content => displayHintExistsWithName("immersive")(content) 27 | 28 | val isPhotoEssay: ContentFilter = content => displayHintExistsWithName("photoEssay")(content) 29 | 30 | val isMedia: ContentFilter = content => tagExistsWithId("type/audio")(content) || tagExistsWithId("type/video")(content) || tagExistsWithId("type/gallery")(content) 31 | 32 | val isReview: ContentFilter = content => tagExistsWithId("tone/reviews")(content) || tagExistsWithId("tone/livereview")(content) || tagExistsWithId("tone/albumreview")(content) 33 | 34 | val isLiveBlog: ContentFilter = content => isLiveBloggingNow(content) && tagExistsWithId("tone/minutebyminute")(content) 35 | 36 | val isDeadBlog: ContentFilter = content => !isLiveBloggingNow(content) && tagExistsWithId("tone/minutebyminute")(content) 37 | 38 | val isInteractive: ContentFilter = content => content.`type` == ContentType.Interactive 39 | 40 | val isPictureContent: ContentFilter = content => content.`type` == ContentType.Picture 41 | 42 | val isGallery: ContentFilter = tagExistsWithId("type/gallery") 43 | 44 | // The date used here is arbitrary and will be moved nearer to the present when the new template feature is ready to be used in production 45 | val immersiveInteractiveSwitchoverDate = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC) 46 | 47 | val publishedBeforeInteractiveImmersiveSwitchover: ContentFilter = content => content.fields.flatMap(_.creationDate).exists(date => ZonedDateTime.parse(date.iso8601).isBefore(immersiveInteractiveSwitchoverDate)) 48 | 49 | val isLegacyImmersiveInteractive: ContentFilter = content => isInteractive(content) && isImmersive(content) && publishedBeforeInteractiveImmersiveSwitchover(content) 50 | 51 | val isObituary: ContentFilter = content => (tagExistsWithId("tone/obituaries")(content) && !tagExistsWithId("tone/letters")(content)) 52 | 53 | val isFullPageInteractive: ContentFilter = content => isInteractive(content) && (displayHintExistsWithName("fullPageInteractive")(content) || isLegacyImmersiveInteractive(content)) 54 | implicit class RichCapiDateTime(val cdt: CapiDateTime) extends AnyVal { 55 | def toOffsetDateTime: OffsetDateTime = OffsetDateTime.parse(cdt.iso8601) 56 | } 57 | 58 | implicit class RichOffsetDateTime(val dt: OffsetDateTime) extends AnyVal { 59 | def toCapiDateTime: CapiDateTime = CapiDateTime.apply(dt.toInstant.toEpochMilli, DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dt)) 60 | } 61 | 62 | implicit class RichContent(val content: Content) extends AnyVal { 63 | 64 | def designType: DesignType = { 65 | 66 | val isComment: ContentFilter = content => tagExistsWithId("tone/comment")(content) || tagExistsWithId("tone/letters")(content) 67 | val defaultDesignType: DesignType = Article 68 | 69 | val predicates: List[(ContentFilter, DesignType)] = List( 70 | tagExistsWithId("tone/advertisement-features") -> AdvertisementFeature, 71 | tagExistsWithId("tone/matchreports") -> MatchReport, 72 | tagExistsWithId("tone/quizzes") -> Quiz, 73 | isImmersive -> Immersive, 74 | tagExistsWithId("tone/editorials") -> GuardianView, 75 | tagExistsWithId("tone/interview") -> Interview, 76 | tagExistsWithId("tone/recipes") -> Recipe, 77 | isMedia -> Media, 78 | isReview -> Review, 79 | tagExistsWithId("tone/analysis") -> Analysis, 80 | isComment -> Comment, 81 | tagExistsWithId("tone/features") -> Feature, 82 | isLiveBlog -> Live, 83 | isDeadBlog -> Article, 84 | tagExistsWithId("tone/newsletter-tone") -> Newsletter 85 | ) 86 | 87 | val result = getFromPredicate(content, predicates) 88 | result.getOrElse(defaultDesignType) 89 | } 90 | } 91 | 92 | implicit class RenderingFormat(val content: Content) extends AnyVal { 93 | 94 | def design: Design = { 95 | 96 | val defaultDesign: Design = ArticleDesign 97 | 98 | // Note: only the first matching predicate will be picked. 99 | // Modifying the order of predicates could create unintended problems: 100 | val predicates: List[(ContentFilter, Design)] = List( 101 | isFullPageInteractive -> FullPageInteractiveDesign, 102 | isInteractive -> InteractiveDesign, 103 | tagExistsWithId("info/newsletter-sign-up") -> NewsletterSignupDesign, 104 | isGallery -> GalleryDesign, 105 | isPictureContent -> PictureDesign, 106 | tagExistsWithId("type/audio") -> AudioDesign, 107 | tagExistsWithId("type/video") -> VideoDesign, 108 | tagExistsWithId("type/crossword") -> CrosswordDesign, 109 | isReview -> ReviewDesign, 110 | isObituary -> ObituaryDesign, 111 | tagExistsWithId("tone/analysis") -> AnalysisDesign, 112 | tagExistsWithId("tone/explainers") -> ExplainerDesign, 113 | tagExistsWithId("tone/comment") -> CommentDesign, 114 | tagExistsWithId("tone/letters") -> LetterDesign, 115 | isPhotoEssay -> PhotoEssayDesign, 116 | tagExistsWithId("tone/interview") -> InterviewDesign, 117 | tagExistsWithId("tone/recipes") -> RecipeDesign, 118 | tagExistsWithId("tone/editorials") -> EditorialDesign, 119 | tagExistsWithId("tone/quizzes") -> QuizDesign, 120 | isLiveBlog -> LiveBlogDesign, 121 | isDeadBlog -> DeadBlogDesign, 122 | tagExistsWithId("tone/features") -> FeatureDesign, 123 | tagExistsWithId("tone/matchreports") -> MatchReportDesign, 124 | tagExistsWithId("tone/timelines") -> TimelineDesign, 125 | tagExistsWithId("tone/profiles") -> ProfileDesign, 126 | ) 127 | 128 | val result = getFromPredicate(content, predicates) 129 | result.getOrElse(defaultDesign) 130 | } 131 | 132 | def theme: Theme = { 133 | val defaultTheme: Theme = NewsPillar 134 | 135 | val specialReportAltTags: Set[String] = Set( 136 | "news/series/cotton-capital", 137 | "news/series/cotton-capital-ongoing-series" 138 | ) 139 | 140 | val specialReportTags: Set[String] = Set( 141 | "business/series/undercover-in-the-chicken-industry", 142 | "business/series/britains-debt-timebomb", 143 | "environment/series/the-polluters", 144 | "news/series/hsbc-files", 145 | "news/series/panama-papers", 146 | "us-news/homan-square", 147 | "uk-news/series/the-new-world-of-work", 148 | "world/series/the-new-arrivals", 149 | "news/series/nauru-files", 150 | "us-news/series/counted-us-police-killings", 151 | "australia-news/series/healthcare-in-detention", 152 | "society/series/this-is-the-nhs", 153 | "news/series/facebook-files", 154 | "news/series/pegasus-project", 155 | "news/series/pandora-papers", 156 | "news/series/suisse-secrets", 157 | "uk-news/series/cost-of-the-crown" 158 | ) 159 | 160 | // Special Report hashes can be generated by executing: 161 | // echo -n '' | md5sum 162 | val hashedSpecialReportTags: Set[String] = Set( 163 | "0d18e8413ab7cdf377e1202d24452e63" 164 | ) 165 | 166 | val hashedSpecialReportAltTags: Set[String] = Set( 167 | ) 168 | 169 | val salt = "a-public-salt3W#ywHav!p+?r+W2$E6=" 170 | 171 | def isPillar(pillar: String): ContentFilter = content => content.pillarName.contains(pillar) 172 | 173 | def hashedTagIds(content: Content) = content.tags.map { tag => 174 | DigestUtils.md5Hex(salt + tag.id) 175 | } 176 | 177 | val isSpecialReport: ContentFilter = content => 178 | content.tags.exists(t => specialReportTags(t.id)) || hashedTagIds(content).exists(hashedSpecialReportTags.apply) 179 | 180 | val isSpecialReportAlt: ContentFilter = content => 181 | content.tags.exists(t => specialReportAltTags(t.id)) || hashedTagIds(content).exists(hashedSpecialReportAltTags.apply) 182 | 183 | val isOpinion: ContentFilter = content => 184 | (tagExistsWithId("tone/comment")(content) && isPillar("News")(content)) || 185 | (tagExistsWithId("tone/letters")(content) && isPillar("News")(content)) || 186 | isPillar("Opinion")(content) 187 | val isCulture: ContentFilter = content => isPillar("Arts")(content) 188 | 189 | val predicates: List[(ContentFilter, Theme)] = List( 190 | isSpecialReport -> SpecialReportTheme, 191 | isSpecialReportAlt -> SpecialReportAltTheme, 192 | tagExistsWithId("tone/advertisement-features") -> Labs, 193 | isOpinion -> OpinionPillar, 194 | isPillar("Sport") -> SportPillar, 195 | isCulture -> CulturePillar, 196 | isPillar("Lifestyle") -> LifestylePillar 197 | ) 198 | 199 | val result = getFromPredicate(content, predicates) 200 | result.getOrElse(defaultTheme) 201 | } 202 | 203 | def display: Display = { 204 | 205 | val defaultDisplay = StandardDisplay 206 | 207 | // We separate this out from the previous isImmersive to prevent breaking the legacy designType when adding 208 | // the logic currently handled on Frontend. isGallery relies on Frontend metadata and so won't be added here 209 | // https://github.com/guardian/frontend/blob/e71dc1c521672b28399811c59331e0c2c713bf00/common/app/model/content.scala#L86 210 | val isImmersiveDisplay: ContentFilter = content => 211 | isImmersive(content) || 212 | isPhotoEssay(content) 213 | 214 | def hasShowcaseImage: ContentFilter = content => { 215 | val hasShowcaseImage = for { 216 | blocks <- content.blocks 217 | main <- blocks.main 218 | mainMedia <- main.elements.headOption 219 | imageTypeData <- mainMedia.imageTypeData 220 | imageRole <- imageTypeData.role 221 | } yield { 222 | imageRole == "showcase" 223 | } 224 | hasShowcaseImage.getOrElse(false) 225 | } 226 | 227 | def hasShowcaseEmbed: ContentFilter = content => { 228 | 229 | def isMainEmbed(elem: Element): Boolean = elem.relation == "main" && elem.`type` == ElementType.Embed 230 | 231 | def hasShowcaseAsset(assets: scala.collection.Seq[Asset]): Boolean = { 232 | val isShowcaseAsset = for { 233 | embedAsset <- assets.find(asset => asset.`type` == AssetType.Embed) 234 | typeData <- embedAsset.typeData 235 | role <- typeData.role 236 | } yield { 237 | role == "showcase" 238 | } 239 | isShowcaseAsset.getOrElse(false) 240 | } 241 | 242 | val hasShowcaseEmbed = for { 243 | elements <- content.elements 244 | mainEmbed <- elements.find(isMainEmbed) 245 | } yield { 246 | hasShowcaseAsset(mainEmbed.assets) 247 | } 248 | 249 | hasShowcaseEmbed.getOrElse(false) 250 | } 251 | 252 | val isShowcase: ContentFilter = content => displayHintExistsWithName("column")(content) || 253 | displayHintExistsWithName("showcase")(content) || 254 | hasShowcaseImage(content) || 255 | hasShowcaseEmbed(content) || 256 | isPictureContent(content) 257 | 258 | val isNumberedList: ContentFilter = displayHintExistsWithName("numberedList") 259 | 260 | val predicates: List[(ContentFilter, Display)] = List( 261 | isFullPageInteractive -> StandardDisplay, 262 | isImmersiveDisplay -> ImmersiveDisplay, 263 | isNumberedList -> NumberedListDisplay, 264 | isShowcase -> ShowcaseDisplay 265 | ) 266 | 267 | val result = getFromPredicate(content, predicates) 268 | result.getOrElse(defaultDisplay) 269 | } 270 | } 271 | 272 | } 273 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/DesignType.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils 2 | 3 | sealed trait DesignType 4 | 5 | case object Article extends DesignType 6 | case object Immersive extends DesignType 7 | case object Media extends DesignType 8 | case object Review extends DesignType 9 | case object Analysis extends DesignType 10 | case object Comment extends DesignType 11 | case object Feature extends DesignType 12 | case object Live extends DesignType 13 | case object SpecialReport extends DesignType 14 | case object Recipe extends DesignType 15 | case object MatchReport extends DesignType 16 | case object Interview extends DesignType 17 | case object GuardianView extends DesignType 18 | case object GuardianLabs extends DesignType 19 | case object Quiz extends DesignType 20 | case object AdvertisementFeature extends DesignType 21 | case object Newsletter extends DesignType 22 | case object Timeline extends DesignType 23 | case object Profile extends DesignType 24 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/QueryStringParams.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils 2 | 3 | import java.net.URLEncoder 4 | 5 | object QueryStringParams { 6 | def apply(parameters: Iterable[(String, String)]) = { 7 | /** 8 | * api-gateway IAM authorisation requires that spaces are encoded as `%20`, not `+`. 9 | * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html 10 | */ 11 | def encodeParameter(p: String): String = URLEncoder.encode(p, "UTF-8").replace("+", "%20") 12 | 13 | if (parameters.isEmpty) { 14 | "" 15 | } else "?" + (parameters map { 16 | case (k, v) => k + "=" + encodeParameter(v) 17 | } mkString "&") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/format/Design.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils.format 2 | 3 | sealed trait Design 4 | 5 | case object ArticleDesign extends Design 6 | case object GalleryDesign extends Design 7 | case object PictureDesign extends Design 8 | case object AudioDesign extends Design 9 | case object VideoDesign extends Design 10 | case object CrosswordDesign extends Design 11 | case object ReviewDesign extends Design 12 | case object AnalysisDesign extends Design 13 | case object ExplainerDesign extends Design 14 | case object CommentDesign extends Design 15 | case object LetterDesign extends Design 16 | case object FeatureDesign extends Design 17 | case object LiveBlogDesign extends Design 18 | case object DeadBlogDesign extends Design 19 | case object RecipeDesign extends Design 20 | case object MatchReportDesign extends Design 21 | case object FullPageInteractiveDesign extends Design 22 | case object InterviewDesign extends Design 23 | case object EditorialDesign extends Design 24 | case object QuizDesign extends Design 25 | case object InteractiveDesign extends Design 26 | case object PhotoEssayDesign extends Design 27 | case object ObituaryDesign extends Design 28 | case object NewsletterSignupDesign extends Design 29 | case object TimelineDesign extends Design 30 | case object ProfileDesign extends Design -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/format/Display.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils.format 2 | 3 | sealed trait Display 4 | 5 | case object StandardDisplay extends Display 6 | case object ImmersiveDisplay extends Display 7 | case object ShowcaseDisplay extends Display 8 | case object NumberedListDisplay extends Display 9 | case object ColumnDisplay extends Display 10 | -------------------------------------------------------------------------------- /client/src/main/scala/com.gu.contentapi.client/utils/format/Theme.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils.format 2 | 3 | sealed trait Theme 4 | 5 | sealed trait Pillar extends Theme 6 | sealed trait Special extends Theme 7 | 8 | case object NewsPillar extends Pillar 9 | case object OpinionPillar extends Pillar 10 | case object SportPillar extends Pillar 11 | case object CulturePillar extends Pillar 12 | case object LifestylePillar extends Pillar 13 | case object SpecialReportTheme extends Special 14 | case object SpecialReportAltTheme extends Special 15 | case object Labs extends Special 16 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/BackoffTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | import com.gu.contentapi.client.model._ 6 | import org.scalatest._ 7 | import org.scalatest.concurrent.ScalaFutures 8 | 9 | import scala.concurrent.duration.Duration 10 | import scala.concurrent.{ExecutionContext, Future} 11 | import org.scalatest.flatspec.AnyFlatSpec 12 | import org.scalatest.matchers.should.Matchers 13 | 14 | class BackoffTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with BeforeAndAfterAll with Inside with Inspectors { 15 | private def NANOS = TimeUnit.NANOSECONDS 16 | private def MILLIS = TimeUnit.MILLISECONDS 17 | 18 | implicit val schedEx: ScheduledExecutor = ScheduledExecutor() 19 | 20 | class RetryableCapiClient(strategy: BackoffStrategy) extends ContentApiClient { 21 | val apiKey = "TEST-API-KEY" 22 | 23 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = { 24 | Future.successful(HttpResponse(Array(), 500, "status")) 25 | } 26 | } 27 | 28 | def clientWithBackoff(strategy: BackoffStrategy): RetryableContentApiClient = new RetryableCapiClient(strategy) with RetryableContentApiClient { 29 | override implicit val executor: ScheduledExecutor = schedEx 30 | override val backoffStrategy: BackoffStrategy = strategy 31 | } 32 | 33 | "Client interface" should "have the expected doubling backoff strategy" in { 34 | val myInterval = 250L 35 | val myRetries = 3 36 | val myStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, MILLIS), myRetries) 37 | val myApi = clientWithBackoff(myStrategy) 38 | val expectedStrategy = Multiple(Duration(myInterval, MILLIS), 0, myRetries, 2.0) 39 | myApi.backoffStrategy should be(expectedStrategy) 40 | } 41 | 42 | it should "have the expected minimum doubling backoff properties" in { 43 | // 10 NANOS should become 250 MILLIS 44 | val myInterval = 10L 45 | val myRetries = 20 46 | val myStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, NANOS), myRetries) 47 | val myApi = clientWithBackoff(myStrategy) 48 | val expectedStrategy = Multiple(Duration(250L, MILLIS), 0, myRetries, 2.0) 49 | myApi.backoffStrategy should be(expectedStrategy) 50 | } 51 | 52 | it should "not allow a doubling strategy with zero retries" in { 53 | val myInterval = 250L 54 | val myRetries = 0 55 | val myStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, MILLIS), myRetries) 56 | val myApi = clientWithBackoff(myStrategy) 57 | val expectedStrategy = Multiple(Duration(myInterval, MILLIS), 0, 1, 2.0) 58 | myApi.backoffStrategy should be(expectedStrategy) 59 | } 60 | 61 | "Client interface with an exponential backoff" should "respect user's parameters if they meet minimum limits" in { 62 | // an exponential backoff allows a minimum interval of 100 MILLIS 63 | val myInterval = 100L 64 | val myRetries = 10 65 | val myStrategy = BackoffStrategy.exponentialStrategy(Duration(myInterval, MILLIS), myRetries) 66 | val myApi = clientWithBackoff(myStrategy) 67 | val expectedStrategy = Exponential(Duration(myInterval, MILLIS), 0, myRetries) 68 | myApi.backoffStrategy should be(expectedStrategy) 69 | } 70 | 71 | it should "protect the backend from abuse by too-low limits" in { 72 | // an attempt to delay just 10 MILLIS should be set to minimum of 100 MILLIS 73 | val myInterval = 10L 74 | val myRetries = 1 75 | val myStrategy = BackoffStrategy.exponentialStrategy(Duration(myInterval, MILLIS), myRetries) 76 | val myApi = clientWithBackoff(myStrategy) 77 | val expectedStrategy = Exponential(Duration(100L, MILLIS), 0, myRetries) 78 | myApi.backoffStrategy should be(expectedStrategy) 79 | } 80 | 81 | it should "not allow an exponential strategy with zero retries" in { 82 | val myInterval = 10L 83 | val myRetries = 0 84 | val myStrategy = BackoffStrategy.exponentialStrategy(Duration(myInterval, MILLIS), myRetries) 85 | val myApi = clientWithBackoff(myStrategy) 86 | val expectedStrategy = Exponential(Duration(100L, MILLIS), 0, 1) 87 | myApi.backoffStrategy should be(expectedStrategy) 88 | } 89 | 90 | it should "respect user's parameters even if they request ridiculous upper limits" in { 91 | val myInterval = 500L 92 | val myRetries = 20 93 | val myStrategy = BackoffStrategy.exponentialStrategy(Duration(myInterval, MILLIS), myRetries) 94 | val myApi = clientWithBackoff(myStrategy) 95 | val expectedStrategy = Exponential(Duration(myInterval, MILLIS), 0, myRetries) 96 | myApi.backoffStrategy should be(expectedStrategy) 97 | } 98 | 99 | "Client interface with a constant wait backoff strategy" should "be initialised correctly" in { 100 | // an exponential backoff allows a minimum interval of 100 MILLIS 101 | val myInterval = 500L 102 | val myRetries = 5 103 | val myStrategy = BackoffStrategy.constantStrategy(Duration(myInterval, MILLIS), myRetries) 104 | val myApi = clientWithBackoff(myStrategy) 105 | val expectedStrategy = Constant(Duration(myInterval, MILLIS), 0, myRetries) 106 | myApi.backoffStrategy should be(expectedStrategy) 107 | } 108 | 109 | it should "respect minimum parameters" in { 110 | val myInterval = 1L 111 | val myRetries = 100 112 | val myStrategy = BackoffStrategy.constantStrategy(Duration(myInterval, MILLIS), myRetries) 113 | val myApi = clientWithBackoff(myStrategy) 114 | val expectedStrategy = Constant(Duration(250L, MILLIS), 0, myRetries) 115 | myApi.backoffStrategy should be(expectedStrategy) 116 | } 117 | 118 | it should "not allow a constant strategy with zero retries" in { 119 | val myInterval = 250L 120 | val myRetries = 0 121 | val myStrategy = BackoffStrategy.constantStrategy(Duration(myInterval, MILLIS), myRetries) 122 | val myApi = clientWithBackoff(myStrategy) 123 | val expectedStrategy = Constant(Duration(myInterval, MILLIS), 0, 1) 124 | myApi.backoffStrategy should be(expectedStrategy) 125 | } 126 | 127 | "When invoked, a doubling backoff strategy" should "increment properly" in { 128 | val myInterval = 250L 129 | val myRetries = 4 130 | val myStrategy = BackoffStrategy.doublingStrategy(Duration(myInterval, MILLIS), myRetries) 131 | val myApi = clientWithBackoff(myStrategy) 132 | 133 | val firstRetry = myApi.backoffStrategy.increment 134 | firstRetry should be(Multiple(Duration(250L, MILLIS), 1, myRetries, 2.0)) 135 | 136 | val secondRetry = firstRetry.increment 137 | secondRetry should be(Multiple(Duration(500L, MILLIS), 2, myRetries, 2.0)) 138 | 139 | val thirdRetry = secondRetry.increment 140 | thirdRetry should be(Multiple(Duration(1000L, MILLIS), 3, myRetries, 2.0)) 141 | 142 | val fourthRetry = thirdRetry.increment 143 | fourthRetry should be(Multiple(Duration(2000L, MILLIS), 4, myRetries, 2.0)) 144 | 145 | val fifthRetry = fourthRetry.increment 146 | fifthRetry should be(com.gu.contentapi.client.RetryFailed(4)) 147 | } 148 | 149 | "When invoked, a multiplier backoff strategy" should "increment backoff values correctly with custom factor" in { 150 | val myInterval = 350L 151 | val myFactor = 3.0 152 | val myRetries = 3 153 | val myStrategy = BackoffStrategy.multiplierStrategy(Duration(myInterval, MILLIS), myRetries, myFactor) 154 | val myApi = clientWithBackoff(myStrategy) 155 | 156 | val firstRetry = myApi.backoffStrategy.increment 157 | firstRetry should be(Multiple(Duration(350L, MILLIS), 1, myRetries, myFactor)) 158 | 159 | val secondRetry = firstRetry.increment 160 | secondRetry should be(Multiple(Duration(1050L, MILLIS), 2, myRetries, myFactor)) 161 | 162 | val thirdRetry = secondRetry.increment 163 | thirdRetry should be(Multiple(Duration(3150L, MILLIS), 3, myRetries, myFactor)) 164 | 165 | val fourthRetry = thirdRetry.increment 166 | fourthRetry should be(com.gu.contentapi.client.RetryFailed(3)) 167 | } 168 | 169 | "When invoked, an exponential backoff strategy" should "increment backoff values correctly" in { 170 | val myInterval = 100L 171 | val myRetries = 4 172 | val myStrategy = BackoffStrategy.exponentialStrategy(Duration(myInterval, MILLIS), myRetries) 173 | val myApi = clientWithBackoff(myStrategy) 174 | 175 | val firstRetry = myApi.backoffStrategy.increment 176 | firstRetry should be(Exponential(Duration(100L, MILLIS), 1, myRetries)) 177 | 178 | val secondRetry = firstRetry.increment 179 | secondRetry should be(Exponential(Duration(200L, MILLIS), 2, myRetries)) 180 | 181 | val thirdRetry = secondRetry.increment 182 | thirdRetry should be(Exponential(Duration(800L, MILLIS), 3, myRetries)) 183 | 184 | val fourthRetry = thirdRetry.increment 185 | fourthRetry should be(Exponential(Duration(6400L, MILLIS), 4, myRetries)) 186 | 187 | val fifthRetry = fourthRetry.increment 188 | fifthRetry should be(com.gu.contentapi.client.RetryFailed(4)) 189 | } 190 | 191 | "When invoked, a constant wait backoff strategy" should "increment itself correctly" in { 192 | val myInterval = 1000L 193 | val myRetries = 3 194 | val myStrategy = BackoffStrategy.constantStrategy(Duration(myInterval, MILLIS), myRetries) 195 | val myApi = clientWithBackoff(myStrategy) 196 | 197 | val firstRetry = myApi.backoffStrategy.increment 198 | firstRetry should be(Constant(Duration(1000L, MILLIS), 1, myRetries)) 199 | 200 | val secondRetry = firstRetry.increment 201 | secondRetry should be(Constant(Duration(1000L, MILLIS), 2, myRetries)) 202 | 203 | val thirdRetry = secondRetry.increment 204 | thirdRetry should be(Constant(Duration(1000L, MILLIS), 3, myRetries)) 205 | 206 | val fourthRetry = thirdRetry.increment 207 | fourthRetry should be(com.gu.contentapi.client.RetryFailed(3)) 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/ContentApiClientTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.model.Direction.{Next, Previous} 4 | import com.gu.contentapi.client.model._ 5 | import com.gu.contentapi.client.model.v1.{Content, SearchResponse} 6 | import org.scalatest._ 7 | import org.scalatest.concurrent.ScalaFutures 8 | import org.scalatest.time.{Seconds, Span} 9 | 10 | import java.time.Instant 11 | import java.util.concurrent.TimeUnit 12 | import scala.concurrent.ExecutionContext.Implicits.global 13 | import scala.concurrent.duration.Duration 14 | import scala.concurrent.{ExecutionContext, Future} 15 | import org.scalatest.flatspec.AnyFlatSpec 16 | import org.scalatest.matchers.should.Matchers 17 | 18 | class ContentApiClientTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with BeforeAndAfterAll with Inside with Inspectors { 19 | private val api = new ContentApiClient { 20 | val retryDuration = Duration(250L, TimeUnit.MILLISECONDS) 21 | val maxRetries = 5 22 | 23 | val apiKey = "TEST-API-KEY" 24 | 25 | def get(url: String, headers: Map[String, String])(implicit context: ExecutionContext): Future[HttpResponse] = 26 | Future.successful(HttpResponse(Array(), 500, "status")) 27 | } 28 | 29 | implicit override val patienceConfig = PatienceConfig(timeout = Span(5, Seconds)) 30 | it should "correctly add API key to request" in { 31 | api.url(ContentApiClient.search) should include(s"api-key=${api.apiKey}") 32 | } 33 | 34 | it should "generate the correct path segments for following a query" in { 35 | val queryForNext = FollowingSearchQuery( 36 | ContentApiClient.search, 37 | "commentisfree/picture/2022/may/15/nicola-jennings-boris-johnson-northern-ireland-cartoon", 38 | Next 39 | ) 40 | 41 | api.url(queryForNext) should include(s"content/commentisfree/picture/2022/may/15/nicola-jennings-boris-johnson-northern-ireland-cartoon/next") 42 | 43 | val queryForPrevious = queryForNext.copy(direction = Previous) 44 | api.url(queryForPrevious) should include(s"content/commentisfree/picture/2022/may/15/nicola-jennings-boris-johnson-northern-ireland-cartoon/prev") 45 | } 46 | 47 | it should "understand custom parameters" in { 48 | val now = Instant.now() 49 | val params = ContentApiClient.search 50 | .stringParam("aStringParam", "foo") 51 | .intParam("aIntParam", 3) 52 | .dateParam("aDateParam", now) 53 | .boolParam("aBoolParam", true) 54 | .parameters 55 | 56 | params.get("aStringParam") should be (Some("foo")) 57 | params.get("aIntParam") should be (Some("3")) 58 | params.get("aDateParam") should be (Some(now.toString)) 59 | params.get("aBoolParam") should be (Some("true")) 60 | } 61 | 62 | behavior of "Paginated queries" 63 | 64 | def stubContent(capiId: String): Content = Content(capiId, webTitle="", webUrl="", apiUrl="") 65 | def stubContents(num: Int) = (1 to num).map(i => stubContent(s"blah-$i")) 66 | def stubSearchResponse(pageSize: Int, orderBy: String, results: Seq[Content]): SearchResponse = SearchResponse( 67 | status = "", userTier="", total = -1, startIndex = -1, currentPage = -1, pages= -1, orderBy = orderBy, 68 | pageSize = pageSize, // Needed for deciding next query 69 | results = results) 70 | 71 | it should "produce next urls for 10 results ordered by relevance" in { 72 | val query = ContentApiClient.search.q("brexit") 73 | val next = query.followingQueryGiven(stubSearchResponse( 74 | pageSize = 10, 75 | orderBy = "relevance", 76 | stubContents(9) :+ stubContent("hello") 77 | ), Next).value 78 | 79 | testPaginatedQuery("content/hello/next", 10, "relevance", Some("brexit"))(next) 80 | } 81 | 82 | it should "produce next urls for 20 results order by newest" in { 83 | val query = ContentApiClient.search.pageSize(20) 84 | val next = query.followingQueryGiven(stubSearchResponse( 85 | pageSize = 20, 86 | orderBy = "newest", 87 | stubContents(19) :+ stubContent("hello") 88 | ), Next).value 89 | 90 | testPaginatedQuery("content/hello/next", 20, "newest")(next) 91 | } 92 | 93 | it should "recover gracefully from error" in { 94 | 95 | val query = SearchQuery() 96 | val errorTest = api.paginateFold(query)(Seq(): Seq[SearchResponse]){ 97 | (response: SearchResponse, acc: Seq[SearchResponse]) => acc :+ response 98 | } recover { 99 | case graceful: ContentApiError => succeed 100 | case notGraceful => fail("Threw the wrong exception") 101 | } 102 | errorTest.futureValue 103 | } 104 | 105 | def testPaginatedQuery(pt: String, page: Int, ob: String, q: Option[String] = None)(query: ContentApiQuery[_]) = { 106 | val ps = query.parameters 107 | query.pathSegment should startWith (pt) 108 | ps.get("page-size") should be (Some(page.toString)) 109 | ps.get("order-by") should be (Some(ob)) 110 | q.map(q => ps.get("q") should be (Some(q))).getOrElse(succeed) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/HttpRetryTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.BackoffStrategy.constantStrategy 4 | import com.gu.contentapi.client.model.HttpResponse 5 | 6 | import scala.concurrent.Future 7 | import scala.concurrent.duration._ 8 | import org.scalatest.matchers.should.Matchers 9 | import org.scalatest.wordspec.AsyncWordSpecLike 10 | 11 | class HttpRetryTest extends AsyncWordSpecLike with Matchers { 12 | 13 | "withRetry" should { 14 | val maxAttempts = 5 15 | val backoffStrategy = constantStrategy(delay = 100.millis, maxAttempts = maxAttempts) 16 | implicit val schedEx: ScheduledExecutor = ScheduledExecutor() 17 | val successResponse = HttpResponse(Array(), 200, "") 18 | val failure = HttpResponse(Array(), 429, "") 19 | 20 | "not retry if we get success response" in { 21 | val httpResponses = List(Future.successful(successResponse), Future.successful(failure), Future.successful(successResponse.copy(statusCode = 503))).iterator 22 | for { 23 | result <- HttpRetry.withRetry(backoffStrategy) { _ => 24 | httpResponses.next() 25 | } 26 | } yield { 27 | result shouldBe successResponse 28 | } 29 | } 30 | 31 | "retry if we get ContentApiRecoverableException" in { 32 | val httpResponses = List(Future.successful(failure), Future.successful(successResponse)).iterator 33 | for { 34 | result <- HttpRetry.withRetry(backoffStrategy) { _ => 35 | httpResponses.next() 36 | } 37 | } yield { 38 | result shouldBe successResponse 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/RetryTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client 2 | 3 | import com.gu.contentapi.client.BackoffStrategy.constantStrategy 4 | import org.scalatest.RecoverMethods 5 | 6 | import scala.concurrent.Future 7 | import scala.concurrent.duration._ 8 | import org.scalatest.matchers.should.Matchers 9 | import org.scalatest.wordspec.AsyncWordSpecLike 10 | 11 | class RetryTest extends AsyncWordSpecLike with Matchers with RecoverMethods { 12 | 13 | "withRetry" should { 14 | val maxAttempts = 5 15 | val backoffStrategy = constantStrategy(delay = 100.millis, maxAttempts = maxAttempts) 16 | implicit val schedEx: ScheduledExecutor = ScheduledExecutor() 17 | 18 | "not retry if operation result is not retryable" in { 19 | val attemptValues: Iterator[Int] = (1 to maxAttempts).iterator 20 | for { 21 | result <- Retry.withRetry[Any](backoffStrategy, _ => false){ _ => 22 | Future.successful(attemptValues.next()) 23 | } 24 | } yield { 25 | result shouldBe 1 26 | } 27 | } 28 | 29 | "retry if operation result is retryable" in { 30 | val attemptValues: Iterator[Int] = (1 to maxAttempts).iterator 31 | for { 32 | result <- Retry.withRetry[Int](backoffStrategy, _ != 4) { _ => 33 | Future.successful(attemptValues.next()) 34 | } 35 | } yield { 36 | result shouldBe 4 37 | } 38 | } 39 | 40 | "return failed Future if retry count exhausted" in { 41 | val attemptValues: Iterator[Int] = (1 to maxAttempts * 2).iterator 42 | val validateFailure = recoverToSucceededIf[ContentApiBackoffException] { 43 | Retry.withRetry[Int](backoffStrategy, _ => true) { _ => 44 | Future.successful(attemptValues.next()) 45 | } 46 | } 47 | for { 48 | _ <- validateFailure 49 | } yield { 50 | attemptValues.next() shouldBe maxAttempts + 1 + 1 // max retry attempts + 1 original attempts + 1 (as it is iterator) 51 | } 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/model/ContentApiErrorTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class ContentApiErrorTest extends AnyFlatSpec with Matchers { 7 | "ContentApiError" should "Handle error responses properly" in { 8 | ContentApiError(HttpResponse(Array(), 500, "error")) should be (ContentApiError(500, "error")) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/model/ContentApiQueryTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class ContentApiQueryTest extends AnyFlatSpec with Matchers { 7 | "ItemQuery" should "be excellent" in { 8 | ItemQuery("profile/robert-berry").showFields("all").getUrl("") shouldEqual 9 | "/profile/robert-berry?show-fields=all" 10 | } 11 | 12 | "ItemQuery" should "be similarly excellent when asked to show alias paths" in { 13 | ItemQuery("profile/justin-pinner").showAliasPaths(true).getUrl("") shouldEqual 14 | "/profile/justin-pinner?show-alias-paths=true" 15 | } 16 | 17 | "ItemQuery" should "accept a channel" in { 18 | ItemQuery("lifeandstyle/thing").withChannelId("recipes").getUrl("") shouldEqual 19 | "/channel/recipes/item/lifeandstyle/thing" 20 | } 21 | 22 | "ItemQuery" should "drop the included channel if asked" in { 23 | ItemQuery("lifeandstyle/thing").withChannelId("recipes").withoutChannelId().getUrl("") shouldEqual 24 | "/lifeandstyle/thing" 25 | } 26 | 27 | "SearchQuery" should "also be excellent" in { 28 | SearchQuery().tag("profile/robert-berry").showElements("all").contentType("article").queryFields("body").getUrl("") shouldEqual 29 | "/search?tag=profile%2Frobert-berry&show-elements=all&type=article&query-fields=body" 30 | } 31 | 32 | "SearchQuery" should "not be perturbed when asked to show alias paths" in { 33 | SearchQuery().tag("profile/justin-pinner").showElements("all").showAliasPaths(true).contentType("article").getUrl("") shouldEqual 34 | "/search?tag=profile%2Fjustin-pinner&show-elements=all&show-alias-paths=true&type=article" 35 | } 36 | 37 | "SearchQuery" should "accept paths as a parameter" in { 38 | SearchQuery().paths("path/one,path/two").getUrl("") shouldEqual 39 | "/search?paths=path%2Fone%2Cpath%2Ftwo" 40 | } 41 | 42 | "SearchQuery" should "include a channel if asked" in { 43 | SearchQuery().withChannel("my-channel").paths("path/one").getUrl("") shouldEqual 44 | "/channel/my-channel/search?paths=path%2Fone" 45 | } 46 | 47 | "SearchQuery" should "drop the included channel if asked" in { 48 | SearchQuery().withChannel("my-channel").withoutChannel().paths("path/one").getUrl("") shouldEqual 49 | "/search?paths=path%2Fone" 50 | } 51 | 52 | "SectionsQuery" should "be beautiful" in { 53 | SectionsQuery().getUrl("") shouldEqual "/sections" 54 | } 55 | 56 | "SectionsQuery" should "add sponsorship-type filter" in { 57 | SectionsQuery().sponsorshipType("paid-content").getUrl("") shouldEqual "/sections?sponsorship-type=paid-content" 58 | } 59 | 60 | "TagsQuery" should "be awesome" in { 61 | TagsQuery().tagType("contributor").getUrl("") shouldEqual "/tags?type=contributor" 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/model/VideoStatsQueryTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class VideoStatsQueryTest extends AnyFlatSpec with Matchers { 7 | 8 | it should "request overall video stats" in { 9 | VideoStatsQuery().pathSegment shouldEqual 10 | "stats/videos" 11 | } 12 | 13 | it should "request video stats by edition only" in { 14 | VideoStatsQuery(Some("uk"), None).pathSegment shouldEqual 15 | "stats/videos/uk" 16 | } 17 | 18 | it should "request video stats by section only" in { 19 | VideoStatsQuery(None, Some("sport")).pathSegment shouldEqual 20 | "stats/videos/sport" 21 | } 22 | 23 | it should "request video stats by edition/section" in { 24 | VideoStatsQuery(Some("uk"), Some("sport")).pathSegment shouldEqual 25 | "stats/videos/uk/sport" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/test/scala/com.gu.contentapi.client/model/utils/CapiModelEnrichmentTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.model.utils 2 | 3 | import com.gu.contentapi.client.model.v1._ 4 | import com.gu.contentapi.client.utils.CapiModelEnrichment._ 5 | import com.gu.contentapi.client.utils._ 6 | import com.gu.contentapi.client.utils.format._ 7 | import org.mockito.Mockito._ 8 | import org.scalatestplus.mockito.MockitoSugar 9 | import org.scalatest.flatspec.AnyFlatSpec 10 | import org.scalatest.matchers.should.Matchers 11 | 12 | class CapiModelEnrichmentDesignTypeTest extends AnyFlatSpec with MockitoSugar with Matchers { 13 | 14 | def fixture = new { 15 | val content: Content = mock[Content] 16 | val tag: Tag = mock[Tag] 17 | val fields: ContentFields = mock[ContentFields] 18 | 19 | when(fields.displayHint) thenReturn None 20 | when(content.tags) thenReturn List(tag) 21 | when(content.fields) thenReturn None 22 | } 23 | 24 | it should "have a designType of 'Media' when tag type/video is present" in { 25 | val f = fixture 26 | when(f.tag.id) thenReturn "type/video" 27 | 28 | f.content.designType shouldEqual Media 29 | } 30 | 31 | it should "have a designType of 'Media' when tag type/audio is present" in { 32 | val f = fixture 33 | when(f.tag.id) thenReturn "type/audio" 34 | 35 | f.content.designType shouldEqual Media 36 | } 37 | 38 | it should "have a designType of 'Media' when tag type/gallery is present" in { 39 | val f = fixture 40 | when(f.tag.id) thenReturn "type/gallery" 41 | 42 | f.content.designType shouldEqual Media 43 | } 44 | 45 | it should "have a designType of 'Review' when tag tone/reviews is present" in { 46 | val f = fixture 47 | when(f.tag.id) thenReturn "tone/reviews" 48 | 49 | f.content.designType shouldEqual Review 50 | } 51 | 52 | it should "have a designType of 'Review' when tag tone/livereview is present" in { 53 | val f = fixture 54 | when(f.tag.id) thenReturn "tone/livereview" 55 | 56 | f.content.designType shouldEqual Review 57 | } 58 | 59 | it should "have a designType of 'Review' when tag tone/albumreview is present" in { 60 | val f = fixture 61 | when(f.tag.id) thenReturn "tone/albumreview" 62 | 63 | f.content.designType shouldEqual Review 64 | } 65 | 66 | it should "have a designType of 'Comment' when tag tone/comment is present" in { 67 | val f = fixture 68 | when(f.tag.id) thenReturn "tone/comment" 69 | 70 | f.content.designType shouldEqual Comment 71 | } 72 | 73 | it should "have a designType of 'Live' when tag tone/minutebyminute is present and is liveblogging" in { 74 | val f = fixture 75 | 76 | when(f.tag.id) thenReturn "tone/minutebyminute" 77 | when(f.fields.liveBloggingNow) thenReturn Some(true) 78 | when(f.content.fields) thenReturn Some(f.fields) 79 | 80 | f.content.designType shouldEqual Live 81 | } 82 | 83 | it should "have a designType of 'Article' when tag tone/minutebyminute is present but not live anymore" in { 84 | val f = fixture 85 | 86 | when(f.tag.id) thenReturn "tone/minutebyminute" 87 | when(f.fields.liveBloggingNow) thenReturn None 88 | when(f.content.fields) thenReturn Some(f.fields) 89 | 90 | f.content.designType shouldEqual Article 91 | } 92 | 93 | it should "have a designType of 'Feature' when tag tone/features is present" in { 94 | val f = fixture 95 | when(f.tag.id) thenReturn "tone/features" 96 | 97 | f.content.designType shouldEqual Feature 98 | } 99 | 100 | 101 | it should "have a designType of 'Analysis' when tag tone/analysis is present" in { 102 | val f = fixture 103 | when(f.tag.id) thenReturn "tone/analysis" 104 | 105 | f.content.designType shouldEqual Analysis 106 | } 107 | 108 | it should "have a designType of 'Immersive' when the displayHint field is set to 'immersive'" in { 109 | val f = fixture 110 | 111 | when(f.tag.id) thenReturn "tone/analysis" 112 | when(f.content.fields) thenReturn Some(f.fields) 113 | when(f.fields.displayHint) thenReturn Some("immersive") 114 | 115 | f.content.designType shouldEqual Immersive 116 | } 117 | 118 | it should "have a designType of 'Quiz' when tag tone/quizzes is present" in { 119 | val f = fixture 120 | when(f.tag.id) thenReturn "tone/quizzes" 121 | 122 | f.content.designType shouldEqual Quiz 123 | } 124 | 125 | it should "have a designType of 'GuardianView' when tag tone/editorials is present" in { 126 | val f = fixture 127 | when(f.tag.id) thenReturn "tone/editorials" 128 | 129 | f.content.designType shouldEqual GuardianView 130 | } 131 | 132 | it should "have a designType of 'Interview' when tag tone/interview is present" in { 133 | val f = fixture 134 | when(f.tag.id) thenReturn "tone/interview" 135 | 136 | f.content.designType shouldEqual Interview 137 | } 138 | 139 | it should "have a designType of 'MatchReport' when tag tone/matchreports is present" in { 140 | val f = fixture 141 | when(f.tag.id) thenReturn "tone/matchreports" 142 | 143 | f.content.designType shouldEqual MatchReport 144 | } 145 | 146 | it should "have a designType of 'Recipe' when tag tone/recipes is present" in { 147 | val f = fixture 148 | when(f.tag.id) thenReturn "tone/recipes" 149 | 150 | f.content.designType shouldEqual Recipe 151 | } 152 | 153 | //test one example of filters being applied in priority order 154 | it should "return a designType of 'Media' over a designType of 'Comment' where tags for both are present'" in { 155 | val content = mock[Content] 156 | val commentTag = mock[Tag] 157 | val videoTag = mock[Tag] 158 | 159 | when(commentTag.id) thenReturn "tone/comment" 160 | when(videoTag.id) thenReturn "type/video" 161 | when(content.fields) thenReturn None 162 | when(content.tags) thenReturn List(commentTag, videoTag) 163 | 164 | content.designType shouldEqual Media 165 | 166 | } 167 | 168 | it should "have a designType of 'Newsletter' when tag tone/newsletter-tone is present" in { 169 | val f = fixture 170 | when(f.tag.id) thenReturn "tone/newsletter-tone" 171 | 172 | f.content.designType shouldEqual Newsletter 173 | } 174 | } 175 | 176 | class CapiModelEnrichmentFormatTest extends AnyFlatSpec with MockitoSugar with Matchers { 177 | 178 | def fixture = new { 179 | val content: Content = mock[Content] 180 | val tag: Tag = mock[Tag] 181 | val fields: ContentFields = mock[ContentFields] 182 | 183 | val blocks: Blocks = mock[Blocks] 184 | val main: Block = mock[Block] 185 | val blockElement: BlockElement = mock[BlockElement] 186 | val imageTypeData: ImageElementFields = mock[ImageElementFields] 187 | 188 | val element: Element = mock[Element] 189 | val asset: Asset = mock[Asset] 190 | val assetFields: AssetFields = mock[AssetFields] 191 | 192 | when(fields.displayHint) thenReturn None 193 | when(content.tags) thenReturn List(tag) 194 | when(content.fields) thenReturn None 195 | when(content.pillarName) thenReturn None 196 | when(content.`type`) thenReturn ContentType.Article 197 | 198 | when(content.blocks) thenReturn None 199 | when(blocks.main) thenReturn None 200 | when(main.elements) thenReturn Seq(blockElement) 201 | when(blockElement.imageTypeData) thenReturn None 202 | 203 | when(content.elements) thenReturn None 204 | when(element.relation) thenReturn "" 205 | when(element.`type`) thenReturn ElementType.Text // Are these acceptable empty values? 206 | when(element.assets) thenReturn Seq(asset) 207 | when(asset.`type`) thenReturn AssetType.Image // Are these acceptable empty values? 208 | when(asset.typeData) thenReturn None 209 | when(assetFields.role) thenReturn None 210 | 211 | } 212 | 213 | behavior of "Format.design" 214 | 215 | it should "have a design of PictureDesign when tag artanddesign/series/guardian-print-shop is present on Picture content" in { 216 | val f = fixture 217 | when(f.tag.id) thenReturn "artanddesign/series/guardian-print-shop" 218 | when(f.content.`type`) thenReturn ContentType.Picture 219 | 220 | f.content.design shouldEqual PictureDesign 221 | } 222 | 223 | it should "have a design of 'AudioDesign' when tag type/audio is present" in { 224 | val f = fixture 225 | when(f.tag.id) thenReturn "type/audio" 226 | 227 | f.content.design shouldEqual AudioDesign 228 | } 229 | 230 | it should "have a design of 'VideoDesign' when tag type/video is present" in { 231 | val f = fixture 232 | when(f.tag.id) thenReturn "type/video" 233 | 234 | f.content.design shouldEqual VideoDesign 235 | } 236 | 237 | it should "have a design of 'GalleryDesign' when tag type/gallery is present" in { 238 | val f = fixture 239 | when(f.tag.id) thenReturn "type/gallery" 240 | 241 | f.content.design shouldEqual GalleryDesign 242 | } 243 | 244 | it should "have a design of 'ReviewDesign' when tag tone/reviews is present" in { 245 | val f = fixture 246 | when(f.tag.id) thenReturn "tone/reviews" 247 | 248 | f.content.design shouldEqual ReviewDesign 249 | } 250 | 251 | it should "have a design of 'ReviewDesign' when tag tone/livereview is present" in { 252 | val f = fixture 253 | when(f.tag.id) thenReturn "tone/livereview" 254 | 255 | f.content.design shouldEqual ReviewDesign 256 | } 257 | 258 | it should "have a design of 'ReviewDesign' when tag tone/albumreview is present" in { 259 | val f = fixture 260 | when(f.tag.id) thenReturn "tone/albumreview" 261 | 262 | f.content.design shouldEqual ReviewDesign 263 | } 264 | 265 | it should "have a design of 'AnalysisDesign' when tag tone/analysis is present" in { 266 | val f = fixture 267 | when(f.tag.id) thenReturn "tone/analysis" 268 | 269 | f.content.design shouldEqual AnalysisDesign 270 | } 271 | 272 | it should "have a design of 'ExplainerDesign' when tag tone/explainers is present" in { 273 | val f = fixture 274 | when(f.tag.id) thenReturn "tone/explainers" 275 | 276 | f.content.design shouldEqual ExplainerDesign 277 | } 278 | 279 | it should "have a design of 'CommentDesign' when tag tone/comment is present" in { 280 | val f = fixture 281 | when(f.tag.id) thenReturn "tone/comment" 282 | 283 | f.content.design shouldEqual CommentDesign 284 | } 285 | 286 | it should "have a design of 'CommentDesign' when tag tone/letters is present" in { 287 | val f = fixture 288 | when(f.tag.id) thenReturn "tone/letters" 289 | 290 | f.content.design shouldEqual LetterDesign 291 | } 292 | 293 | it should "have a design of 'ObituaryDesign' when tag tone/obituaries is present" in { 294 | val f = fixture 295 | when(f.tag.id) thenReturn "tone/obituaries" 296 | 297 | f.content.design shouldEqual ObituaryDesign 298 | } 299 | 300 | it should "have a design of 'FeatureDesign' when tag tone/features is present" in { 301 | val f = fixture 302 | when(f.tag.id) thenReturn "tone/features" 303 | 304 | f.content.design shouldEqual FeatureDesign 305 | } 306 | 307 | it should "have a design of 'RecipeDesign' when tag tone/recipes is present" in { 308 | val f = fixture 309 | when(f.tag.id) thenReturn "tone/recipes" 310 | 311 | f.content.design shouldEqual RecipeDesign 312 | } 313 | 314 | it should "have a design of 'MatchReportDesign' when tag tone/matchreports is present" in { 315 | val f = fixture 316 | when(f.tag.id) thenReturn "tone/matchreports" 317 | 318 | f.content.design shouldEqual MatchReportDesign 319 | } 320 | 321 | it should "have a design of 'InterviewDesign' when tag tone/interview is present" in { 322 | val f = fixture 323 | when(f.tag.id) thenReturn "tone/interview" 324 | 325 | f.content.design shouldEqual InterviewDesign 326 | } 327 | 328 | it should "have a design of 'EditorialDesign' when tag tone/editorials is present" in { 329 | val f = fixture 330 | when(f.tag.id) thenReturn "tone/editorials" 331 | 332 | f.content.design shouldEqual EditorialDesign 333 | } 334 | 335 | it should "have a design of 'QuizDesign' when tag tone/quizzes is present" in { 336 | val f = fixture 337 | when(f.tag.id) thenReturn "tone/quizzes" 338 | 339 | f.content.design shouldEqual QuizDesign 340 | } 341 | 342 | it should "have a design of 'FullPageInteractiveDesign' when ContentType is " + 343 | "Interactive and display hint is 'fullPageInteractive" in { 344 | val f = fixture 345 | when(f.content.`type`) thenReturn ContentType.Interactive 346 | when(f.fields.displayHint) thenReturn Some("fullPageInteractive") 347 | when(f.content.fields) thenReturn Some(f.fields) 348 | 349 | f.content.display shouldEqual StandardDisplay 350 | f.content.design shouldEqual FullPageInteractiveDesign 351 | } 352 | 353 | it should "have a design of 'FullPageInteractiveDesign' when is legacy " + 354 | "immersive interactive and display hint is 'immersive" in { 355 | val f = fixture 356 | when(f.content.`type`) thenReturn ContentType.Interactive 357 | when(f.fields.displayHint) thenReturn Some("immersive") 358 | when(f.content.fields) thenReturn Some(f.fields) 359 | when(f.fields.creationDate) thenReturn Some(CapiDateTime(1632116952, "2021-09-20T06:49:12Z")) 360 | 361 | f.content.display shouldEqual StandardDisplay 362 | f.content.design shouldEqual FullPageInteractiveDesign 363 | } 364 | 365 | it should "not have a design of 'FullPageInteractiveDesign' " + 366 | "when display hint is 'fullPageInteractive and ContentType is 'Article" in { 367 | val f = fixture 368 | when(f.content.`type`) thenReturn ContentType.Article 369 | when(f.fields.displayHint) thenReturn Some("fullPageInteractive") 370 | 371 | f.content.design shouldEqual ArticleDesign 372 | f.content.design should not equal FullPageInteractiveDesign 373 | } 374 | 375 | it should "have a design of 'InteractiveDesign' when ContentType is Interactive" in { 376 | val f = fixture 377 | when(f.content.`type`) thenReturn ContentType.Interactive 378 | 379 | f.content.design shouldEqual InteractiveDesign 380 | } 381 | 382 | it should "have a design of 'PhotoEssayDesign' when displayHint contains photoEssay" in { 383 | val f = fixture 384 | when(f.fields.displayHint) thenReturn Some("photoEssay") 385 | when(f.content.fields) thenReturn Some(f.fields) 386 | 387 | f.content.design shouldEqual PhotoEssayDesign 388 | } 389 | 390 | it should "have a design of 'LiveBlogDesign' when tag tone/minutebyminute is present and is liveblogging" in { 391 | val f = fixture 392 | 393 | when(f.tag.id) thenReturn "tone/minutebyminute" 394 | when(f.fields.liveBloggingNow) thenReturn Some(true) 395 | when(f.content.fields) thenReturn Some(f.fields) 396 | 397 | f.content.design shouldEqual LiveBlogDesign 398 | } 399 | 400 | it should "have a design of 'DeadBlogDesign' when tag tone/minutebyminute is present but not live anymore" in { 401 | val f = fixture 402 | 403 | when(f.tag.id) thenReturn "tone/minutebyminute" 404 | when(f.fields.liveBloggingNow) thenReturn None 405 | when(f.content.fields) thenReturn Some(f.fields) 406 | 407 | f.content.design shouldEqual DeadBlogDesign 408 | } 409 | 410 | it should "have a design of 'ArticleDesign' when no predicates match" in { 411 | val f = fixture 412 | 413 | f.content.design shouldEqual ArticleDesign 414 | } 415 | 416 | //test examples of filters being applied in priority order 417 | it should "return a design of 'VideoDesign' over a design of 'CommentDesign' where tags for both are present'" in { 418 | val content = mock[Content] 419 | val commentTag = mock[Tag] 420 | val videoTag = mock[Tag] 421 | 422 | when(commentTag.id) thenReturn "tone/comment" 423 | when(videoTag.id) thenReturn "type/video" 424 | when(content.fields) thenReturn None 425 | when(content.tags) thenReturn List(commentTag, videoTag) 426 | 427 | content.design shouldEqual VideoDesign 428 | } 429 | 430 | it should "return a design of 'InterviewDesign' over a design of 'FeatureDesign' where tags for both are present'" in { 431 | val content = mock[Content] 432 | val interviewTag = mock[Tag] 433 | val featureTag = mock[Tag] 434 | 435 | when(interviewTag.id) thenReturn "tone/interview" 436 | when(featureTag.id) thenReturn "tone/features" 437 | when(content.fields) thenReturn None 438 | when(content.tags) thenReturn List(interviewTag, featureTag) 439 | 440 | content.design shouldEqual InterviewDesign 441 | } 442 | 443 | it should "return a design of 'PhotoEssayDesign' over a design of 'FeatureDesign' where information for both is present'" in { 444 | val f = fixture 445 | 446 | when(f.tag.id) thenReturn "tone/features" 447 | when(f.fields.displayHint) thenReturn Some("photoEssay") 448 | when(f.content.fields) thenReturn Some(f.fields) 449 | 450 | f.content.design shouldEqual PhotoEssayDesign 451 | } 452 | 453 | it should "return a design of 'ObituaryDesign' over a design of 'FeatureDesign' where information for both is present'" in { 454 | val f = fixture 455 | 456 | when(f.tag.id) thenReturn "tone/obituaries" 457 | when(f.fields.displayHint) thenReturn Some("photoEssay") 458 | when(f.content.fields) thenReturn Some(f.fields) 459 | 460 | f.content.design shouldEqual ObituaryDesign 461 | } 462 | 463 | it should "have a design of 'NewsletterSignupDesign' when tag info/newsletter-sign-up is present" in { 464 | val f = fixture 465 | when(f.tag.id) thenReturn "info/newsletter-sign-up" 466 | 467 | f.content.design shouldEqual NewsletterSignupDesign 468 | } 469 | 470 | it should "have a design of 'TimelineDesign' when tag tone/timelines is present" in { 471 | val f = fixture 472 | when(f.tag.id) thenReturn "tone/timelines" 473 | 474 | f.content.design shouldEqual TimelineDesign 475 | } 476 | 477 | it should "have a design of 'ProfileDesign' when tag tone/profiles is present" in { 478 | val f = fixture 479 | when(f.tag.id) thenReturn "tone/profiles" 480 | 481 | f.content.design shouldEqual ProfileDesign 482 | } 483 | 484 | behavior of "Format.theme" 485 | 486 | it should "return a theme of 'OpinionPillar' when tag tone/comment is present and has a pillar of 'NewsPillar'" in { 487 | val f = fixture 488 | when(f.content.pillarName) thenReturn Some("News") 489 | when(f.tag.id) thenReturn "tone/comment" 490 | 491 | f.content.theme shouldEqual OpinionPillar 492 | } 493 | 494 | it should "return a theme of 'OpinionPillar' when tag tone/letters is present and has a pillar of 'NewsPillar'" in { 495 | val f = fixture 496 | when(f.content.pillarName) thenReturn Some("News") 497 | when(f.tag.id) thenReturn "tone/letters" 498 | 499 | f.content.theme shouldEqual OpinionPillar 500 | } 501 | 502 | it should "return a theme of 'SportPillar' when has a pillarName of 'Sport'" in { 503 | val f = fixture 504 | when(f.content.pillarName) thenReturn Some("Sport") 505 | 506 | f.content.theme shouldEqual SportPillar 507 | } 508 | 509 | it should "return a theme of 'CulturePillar' when has a pillarName of 'Arts'" in { 510 | val f = fixture 511 | when(f.content.pillarName) thenReturn Some("Arts") 512 | 513 | f.content.theme shouldEqual CulturePillar 514 | } 515 | 516 | 517 | it should "return a theme of 'LifestylePillar' when has a pillarName of 'Lifestyle'" in { 518 | val f = fixture 519 | when(f.content.pillarName) thenReturn Some("Lifestyle") 520 | 521 | f.content.theme shouldEqual LifestylePillar 522 | } 523 | 524 | it should "return a theme of 'SpecialReportTheme' when tag business/series/undercover-in-the-chicken-industry is present" in { 525 | val f = fixture 526 | when(f.tag.id) thenReturn "business/series/undercover-in-the-chicken-industry" 527 | f.content.theme shouldEqual SpecialReportTheme 528 | } 529 | 530 | it should "return a theme of 'SpecialReportTheme' when tag business/series/britains-debt-timebomb is present" in { 531 | val f = fixture 532 | when(f.tag.id) thenReturn "business/series/britains-debt-timebomb" 533 | 534 | f.content.theme shouldEqual SpecialReportTheme 535 | } 536 | 537 | it should "return a theme of 'SpecialReportTheme' when tag environment/series/the-polluters is present" in { 538 | val f = fixture 539 | when(f.tag.id) thenReturn "environment/series/the-polluters" 540 | 541 | f.content.theme shouldEqual SpecialReportTheme 542 | } 543 | 544 | it should "return a theme of 'SpecialReportTheme' when tag news/series/hsbc-files is present" in { 545 | val f = fixture 546 | when(f.tag.id) thenReturn "news/series/hsbc-files" 547 | 548 | f.content.theme shouldEqual SpecialReportTheme 549 | } 550 | 551 | it should "return a theme of 'SpecialReportTheme' when tag news/series/panama-papers is present" in { 552 | val f = fixture 553 | when(f.tag.id) thenReturn "news/series/panama-papers" 554 | 555 | f.content.theme shouldEqual SpecialReportTheme 556 | } 557 | 558 | it should "return a theme of 'SpecialReportTheme' when tag us-news/homan-square is present" in { 559 | val f = fixture 560 | when(f.tag.id) thenReturn "us-news/homan-square" 561 | 562 | f.content.theme shouldEqual SpecialReportTheme 563 | } 564 | 565 | it should "return a theme of 'SpecialReportTheme' when tag uk-news/series/the-new-world-of-work is present" in { 566 | val f = fixture 567 | when(f.tag.id) thenReturn "uk-news/series/the-new-world-of-work" 568 | 569 | f.content.theme shouldEqual SpecialReportTheme 570 | } 571 | 572 | it should "return a theme of 'SpecialReportTheme' when tag world/series/the-new-arrivals is present" in { 573 | val f = fixture 574 | when(f.tag.id) thenReturn "world/series/the-new-arrivals" 575 | 576 | f.content.theme shouldEqual SpecialReportTheme 577 | } 578 | 579 | it should "return a theme of 'SpecialReportTheme' when tag news/series/nauru-files is present" in { 580 | val f = fixture 581 | when(f.tag.id) thenReturn "news/series/nauru-files" 582 | 583 | f.content.theme shouldEqual SpecialReportTheme 584 | } 585 | 586 | it should "return a theme of 'SpecialReportTheme' when tag us-news/series/counted-us-police-killings is present" in { 587 | val f = fixture 588 | when(f.tag.id) thenReturn "us-news/series/counted-us-police-killings" 589 | 590 | f.content.theme shouldEqual SpecialReportTheme 591 | } 592 | 593 | it should "return a theme of 'SpecialReportTheme' when tag australia-news/series/healthcare-in-detention is present" in { 594 | val f = fixture 595 | when(f.tag.id) thenReturn "australia-news/series/healthcare-in-detention" 596 | 597 | f.content.theme shouldEqual SpecialReportTheme 598 | } 599 | 600 | it should "return a theme of 'SpecialReportTheme' when tag society/series/this-is-the-nhs is present" in { 601 | val f = fixture 602 | when(f.tag.id) thenReturn "society/series/this-is-the-nhs" 603 | 604 | f.content.theme shouldEqual SpecialReportTheme 605 | } 606 | 607 | it should "return a theme of 'SpecialReportTheme' when it is also an Opinion piece" in { 608 | 609 | val content = mock[Content] 610 | val commentTag = mock[Tag] 611 | val specialReportTag = mock[Tag] 612 | 613 | 614 | when(specialReportTag.id) thenReturn "society/series/this-is-the-nhs" 615 | when(commentTag.id) thenReturn "tone/letters" 616 | when(content.fields) thenReturn None 617 | when(content.tags) thenReturn List(commentTag, specialReportTag) 618 | 619 | content.theme shouldEqual SpecialReportTheme 620 | } 621 | 622 | it should "return a theme of 'Labs' when tag tone/advertisement-features is present" in { 623 | val f = fixture 624 | when(f.tag.id) thenReturn "tone/advertisement-features" 625 | 626 | f.content.theme shouldEqual Labs 627 | } 628 | 629 | it should "return a theme of 'Labs' when tag tone/advertisement-features is present and any pillarName is set" in { 630 | val f = fixture 631 | when(f.tag.id) thenReturn "tone/advertisement-features" 632 | when(f.content.pillarName) thenReturn Some("Lifestyle") 633 | 634 | 635 | f.content.theme shouldEqual Labs 636 | } 637 | 638 | it should "return a theme of 'NewsPillar' when no predicates match" in { 639 | val f = fixture 640 | f.content.theme shouldEqual NewsPillar 641 | } 642 | 643 | behavior of "Format.display" 644 | 645 | it should "return a display of 'ImmersiveDisplay' when a displayHint of immersive is set" in { 646 | val f = fixture 647 | when(f.content.fields) thenReturn Some(f.fields) 648 | when(f.fields.displayHint) thenReturn Some("immersive") 649 | f.content.display shouldEqual ImmersiveDisplay 650 | } 651 | 652 | it should "return a display of 'ImmersiveDisplay' when a displayHint of photoEssay is set" in { 653 | val f = fixture 654 | when(f.content.fields) thenReturn Some(f.fields) 655 | when(f.fields.displayHint) thenReturn Some("photoEssay") 656 | f.content.display shouldEqual ImmersiveDisplay 657 | } 658 | 659 | it should "return a display of 'ShowcaseDisplay' when a showcaseImage is set" in { 660 | val f = fixture 661 | 662 | when(f.content.blocks) thenReturn Some(f.blocks) 663 | when(f.blocks.main) thenReturn Some(f.main) 664 | when(f.blockElement.imageTypeData) thenReturn Some(f.imageTypeData) 665 | when(f.imageTypeData.role) thenReturn Some("showcase") 666 | 667 | f.content.display shouldEqual ShowcaseDisplay 668 | 669 | } 670 | 671 | it should "return a display of 'ShowcaseDisplay' when a showcaseEmbed is set" in { 672 | val f = fixture 673 | 674 | when(f.content.elements) thenReturn Some(scala.collection.Seq(f.element)) 675 | when(f.element.relation) thenReturn "main" 676 | when(f.element.`type`) thenReturn ElementType.Embed 677 | when(f.asset.`type`) thenReturn AssetType.Embed 678 | when(f.asset.typeData) thenReturn Some(f.assetFields) 679 | when(f.assetFields.role) thenReturn Some("showcase") 680 | 681 | f.content.display shouldEqual ShowcaseDisplay 682 | 683 | } 684 | 685 | it should "return a display of 'NumberedListDisplay' when a displayHint of numberedList is set" in { 686 | val f = fixture 687 | when(f.content.fields) thenReturn Some(f.fields) 688 | when(f.fields.displayHint) thenReturn Some("numberedList") 689 | f.content.display shouldEqual NumberedListDisplay 690 | } 691 | 692 | it should "return a display of 'NumberedListDisplay' when a displayHint of numberedList is set and a showcase element is present" in { 693 | val f = fixture 694 | when(f.content.fields) thenReturn Some(f.fields) 695 | when(f.fields.displayHint) thenReturn Some("numberedList") 696 | 697 | when(f.content.elements) thenReturn Some(scala.collection.Seq(f.element)) 698 | when(f.element.relation) thenReturn "main" 699 | when(f.element.`type`) thenReturn ElementType.Embed 700 | when(f.asset.`type`) thenReturn AssetType.Embed 701 | when(f.asset.typeData) thenReturn Some(f.assetFields) 702 | when(f.assetFields.role) thenReturn Some("showcase") 703 | 704 | f.content.display shouldEqual NumberedListDisplay 705 | } 706 | 707 | it should "return a display of 'StandardDisplay' when no predicates are set" in { 708 | val f = fixture 709 | 710 | f.content.display shouldEqual StandardDisplay 711 | } 712 | 713 | it should "confirm interactive content made in 2021 is legacy interactive content" in { 714 | val f = fixture 715 | when(f.content.fields) thenReturn Some(f.fields) 716 | when(f.fields.creationDate) thenReturn Some(CapiDateTime(1632116952, "2021-09-20T06:49:12Z")) 717 | 718 | publishedBeforeInteractiveImmersiveSwitchover(f.content) shouldEqual true 719 | } 720 | 721 | it should "confirm interactive content made in 2026 is not legacy interactive content" in { 722 | val f = fixture 723 | when(f.content.fields) thenReturn Some(f.fields) 724 | when(f.fields.creationDate) thenReturn Some(CapiDateTime(1695188952, "2026-09-20T06:49:12Z")) 725 | 726 | publishedBeforeInteractiveImmersiveSwitchover(f.content) shouldEqual false 727 | } 728 | 729 | } 730 | -------------------------------------------------------------------------------- /client/src/test/scala/com/gu/contentapi/client/utils/QueryStringParamsTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.contentapi.client.utils 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class QueryStringParamsTest extends AnyFlatSpec with Matchers { 7 | 8 | "QueryStringParams" should "correctly encode GET query string parameters" in { 9 | 10 | val questionableParams = Seq( 11 | ("foo", "bar"), 12 | ("withPlus", "1+2=3"), 13 | ("withPipe", "(tone/analytics|tone/comment)") 14 | ) 15 | 16 | QueryStringParams.apply(questionableParams) should be("?foo=bar&withPlus=1%2B2%3D3&withPipe=%28tone%2Fanalytics%7Ctone%2Fcomment%29") 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /docs/images/pre-release-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guardian/content-api-scala-client/6ce71fd621ac0085c5f5c445f0a7036667b5a7fb/docs/images/pre-release-view.png -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Publishing a new release 2 | 3 | This repo uses [`gha-scala-library-release-workflow`](https://github.com/guardian/gha-scala-library-release-workflow) 4 | to automate publishing releases (both full & preview releases) - see 5 | [**Making a Release**](https://github.com/guardian/gha-scala-library-release-workflow/blob/main/docs/making-a-release.md). 6 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val scalaVersions = Seq("2.12.19", "2.13.14") 5 | val capiModelsVersion = "27.1.0" 6 | val thriftVersion = "0.20.0" 7 | val commonsCodecVersion = "1.17.0" 8 | val scalaTestVersion = "3.2.18" 9 | val slf4jVersion = "2.0.13" 10 | val mockitoVersion = "5.12.0" 11 | val okhttpVersion = "4.12.0" 12 | val awsSdkVersion = "1.11.280" 13 | 14 | // Note: keep libthrift at a version functionally compatible with that used in content-api-models 15 | // if build failures occur due to eviction / sbt-assembly mergeStrategy errors 16 | val clientDeps = Seq( 17 | "com.gu" %% "content-api-models-scala" % capiModelsVersion, 18 | "org.apache.thrift" % "libthrift" % thriftVersion, 19 | "commons-codec" % "commons-codec" % commonsCodecVersion, 20 | "org.scalatest" %% "scalatest" % scalaTestVersion % "test" exclude("org.mockito", "mockito-core"), 21 | "org.scalatestplus" %% "mockito-4-11" % "3.2.18.0" % "test", 22 | "org.slf4j" % "slf4j-api" % slf4jVersion, 23 | "org.mockito" % "mockito-core" % mockitoVersion 24 | ) 25 | 26 | val defaultClientDeps = Seq( 27 | "com.squareup.okhttp3" % "okhttp" % okhttpVersion 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.0 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.10.0") 2 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") 3 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0") 4 | addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "3.2.1") -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "34.1.2-SNAPSHOT" 2 | --------------------------------------------------------------------------------