├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── release.yml │ └── snyk.yml ├── .gitignore ├── .scalafmt.conf ├── CHANGELOG ├── CONTRIBUTING.md ├── LICENSE-2.0.txt ├── README.md ├── build.sbt ├── project ├── BuildSettings.scala ├── Dependencies.scala ├── build.properties └── plugins.sbt └── src ├── main └── scala │ └── com.snowplowanalytics │ └── forex │ ├── Forex.scala │ ├── OerClient.scala │ ├── Transport.scala │ ├── ZonedClock.scala │ ├── errors.scala │ ├── model.scala │ ├── package.scala │ └── responses.scala ├── site-preprocess └── index.html └── test ├── resources └── mockito-extensions │ └── org.mockito.plugins.MockMaker └── scala └── com.snowplowanalytics.forex ├── ForexAtSpec.scala ├── ForexEodSpec.scala ├── ForexNowSpec.scala ├── ForexNowishSpec.scala ├── ForexWithoutCachesSpec.scala ├── OerClientSpec.scala ├── SpiedCacheSpec.scala └── UnsupportedEodSpec.scala /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | **Library version**: 12 | 13 | **Expected behavior**: 14 | 15 | **Actual behavior**: 16 | 17 | **Steps to reproduce**: 18 | 19 | Please try to be as detailed as possible so that we can reproduce and fix the issue 20 | as quickly as possible. 21 | 22 | 1. 23 | 2. 24 | 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: coursier/cache-action@v6 16 | 17 | - name: Set up JDK 11 18 | uses: actions/setup-java@v1 19 | with: 20 | java-version: 11 21 | 22 | - name: Check Scala formatting 23 | run: sbt scalafmtCheckAll scalafmtSbtCheck 24 | 25 | - name: Run tests with coverage 26 | run: sbt clean coverage +test 27 | 28 | - name: Check assets can be published 29 | run: sbt +publishLocal 30 | 31 | - name: Submit coveralls data 32 | run: sbt coverageReport coveralls 33 | env: 34 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: coursier/cache-action@v6 14 | 15 | - name: Make site 16 | run: sbt makeSite 17 | 18 | - name: Publish ScalaDoc 19 | uses: JamesIves/github-pages-deploy-action@v4.2.5 20 | with: 21 | branch: gh-pages 22 | folder: target/site 23 | clean: false 24 | 25 | - name: Deploy to Maven Central 26 | run: sbt ci-release 27 | env: 28 | PGP_PASSPHRASE: ${{ secrets.SONA_PGP_PASSPHRASE }} 29 | PGP_SECRET: ${{ secrets.SONA_PGP_SECRET }} 30 | SONATYPE_USERNAME: ${{ secrets.SONA_USER }} 31 | SONATYPE_PASSWORD: ${{ secrets.SONA_PASS }} 32 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yml: -------------------------------------------------------------------------------- 1 | name: Snyk 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | security: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Run Snyk to check for vulnerabilities 15 | uses: snyk/actions/scala@master 16 | with: 17 | command: monitor 18 | args: --project-name=scala-forex 19 | env: 20 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.6.4" 2 | style = default 3 | maxColumn = 120 4 | optIn.breakChainOnFirstMethodDot = false 5 | assumeStandardLibraryStripMargin = true 6 | align = most 7 | align.tokens.add = ["|", "!", "!!", "||", "=>", "=", "->", "<-", "|@|", "//", "/", "+", "%", "%%"] 8 | continuationIndent.defnSite = 2 9 | rewrite.rules = [ 10 | AsciiSortImports, 11 | AvoidInfix, 12 | PreferCurlyFors, 13 | RedundantBraces, 14 | RedundantParens, 15 | SortModifiers 16 | ] 17 | project.git = true 18 | includeNoParensInSelectChains = true 19 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Version 3.0.0 (2023-12-05) 2 | -------------------------- 3 | Bump circe to 0.14.3 (#188) 4 | 5 | Version 2.0.0 (2022-03-09) 6 | -------------------------- 7 | Add cross-compilation for Scala 2.13 (#187) 8 | Migrate to GitHub Actions (#185) 9 | Extend copyright to 2022 (#184) 10 | Bump Javac to 11 (#183) 11 | Bump Scala to 2.12.15 (#182) 12 | Bump scala-lru-map to 0.6.0 (#180) 13 | Bump SBT to 1.6.2 (#181) 14 | 15 | Version 1.0.0 (2020-08-27) 16 | -------------------------- 17 | Reformat code (#174) 18 | Update scalafmt to newest (#173) 19 | Bump scala-lru-map to 0.5.0 (#172) 20 | Bump Circe and Cats Effect (#170) 21 | 22 | Version 0.7.0 (2019-11-11) 23 | -------------------------- 24 | Introduce tagless final encoding (#158) 25 | Abstract over Forex creation (#161) 26 | Support cats.Id (#163) 27 | Introduce scalaj (#162) 28 | Remove infinite recursion when converting currencies (#166) 29 | Turn objects into case objects (#167) 30 | Skip tests if the API key is absent (#148) 31 | Add readme examples (#164) 32 | Bump Scala to 2.12.8 (#149) 33 | Bump joda-convert to 2.2.0 (#150) 34 | Bump cats-effect to 1.2.0 (#151) 35 | Bump circe to 0.11.1 (#152) 36 | Bump scala-lru-map to 0.3.0 (#154) 37 | Bump specs2 to 4.4.1 (#153) 38 | Bump SBT to 1.2.8 (#159) 39 | Use sbt-tpolecat (#147) 40 | Remove Scala 2.11 (#157) 41 | Change Travis distribution to Trusty (#168) 42 | Extend copyright to 2019 (#160) 43 | 44 | Version 0.6.0 (2018-10-03) 45 | -------------------------- 46 | Add code coverage reports (#144) 47 | Add Scala version-dependent CI (#145) 48 | Publish scaladoc (#113) 49 | Update README (#142) 50 | Remove usage of String as currency code, use CurrencyUnit instead (#141) 51 | Extend copyright notices (#140) 52 | Support Scala 2.12 (#122) 53 | Replace twitter-utils with scala-lru-map (#125) 54 | Make the API referentially transparent using cats-effect (#118) 55 | Migrate to circe (#119) 56 | Move from joda-time to java.time (#123) 57 | Use oraclejdk8 in travis builds (#137) 58 | Replace release badge with maven-central badge (#136) 59 | Add CONTRIBUTING.md (#135) 60 | Add pull request template (#134) 61 | Add issue template (#133) 62 | Drop support for Scala 2.10 (#132) 63 | Replace mockito with specs2-mock (#130) 64 | Bump specs2 version (#131) 65 | Bump joda-convert version (#129) 66 | Bump joda-money version (#124) 67 | Add scalafmt (#128) 68 | Bump sbt version to 1.2.3 (#126) 69 | Bump sbt-bintray to 0.5.4 (#127) 70 | Simplify .gitignore (#121) 71 | Remove vagrant setup (#120) 72 | Add Gitter badge (#115) 73 | Update README markdown in according with CommonMark (#114) 74 | 75 | Version 0.5.0 (2017-01-24) 76 | -------------------------- 77 | Bump crossScalaVersions to 2.10.6 and 2.11.8 (#102) 78 | Drop Scala 2.9 support (#105) 79 | Bump to sbt 0.13.13 (#106) 80 | Remove scala-util dependency (#101) 81 | Add CI/CD (#103) 82 | Add Bintray credentials to .travis.yml (#104) 83 | Update Open Exchange Rates credentials in .travis.yml (#109) 84 | Add Sonatype credentials to .travis.yml (#111) 85 | 86 | Version 0.4.0 (2016-02-16) 87 | -------------------------- 88 | Fixed handling of invalid API key (#96) 89 | Fixed exception on 1-to-1 currency conversion (#97) 90 | Fixed OpenJDK build in Travis CI (#98) 91 | 92 | Version 0.3.0 (2015-06-20) 93 | -------------------------- 94 | Switched from LruMap to SynchronizedLruMap (#89) 95 | Fixed to latest Peru version (#91) 96 | 97 | Version 0.2.0 (2015-01-20) 98 | -------------------------- 99 | Added cross-build for Scala 2.11.5 (#80) 100 | Removed Scala 2.9.2 support (#87) 101 | Updated Travis to test against Scala 2.11.5 (#84) 102 | Tweaked twitter/util and Specs2 dependencies (#85) 103 | Added License button to README (#83) 104 | Added Release button to README (#82) 105 | Added dedicated Vagrant setup (#81) 106 | Bumped SBT to 0.13.2 (#86) 107 | 108 | Version 0.1.0 (2014-01-18) 109 | -------------------------- 110 | Initial release 111 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor guide 2 | 3 | The Scala Forex library is maintained by the pipeline team at Snowplow Analytics and improved on by 4 | external contributors for which we are extremely grateful. 5 | 6 | ## Getting in touch 7 | 8 | ### Community support requests 9 | 10 | First and foremost, please do not log an issue if you are asking a general question, all of our community support requests 11 | go through our Discourse forum: https://discourse.snowplowanalytics.com/. 12 | 13 | Posting your problem there ensures more people will see it and you should get support faster than creating a new issue on 14 | GitHub. Please do create a new issue on GitHub if you think you've found a bug or if you would like to submit a feature 15 | request though! 16 | 17 | ### Gitter 18 | 19 | If you want to discuss already created issues, potential bugs, new features you would like to work on or any kind of developer 20 | chat, you can head over to our [Gitter room](https://gitter.im/snowplow/scala-forex). 21 | 22 | ## Issues 23 | 24 | ### Creating an issue 25 | 26 | The project contains an issue template which should help guiding you through the process. However, please keep in mind 27 | that support requests should go to our Discourse forum: https://discourse.snowplowanalytics.com/ and not GitHub issues. 28 | 29 | It's also a good idea to log an issue before starting to work on a pull request to discuss it with the maintainers. 30 | 31 | ### Working on an issue 32 | 33 | If you see an issue you would like to work on, please let us know in the issue! That will help us in terms of scheduling and 34 | not doubling the amount of work. 35 | 36 | If you don't know where to start contributing, you can look at 37 | [the issues labeled `good first issue`](https://github.com/snowplow/scala-forex/labels/good%20first%20issue). 38 | 39 | ## Pull requests 40 | 41 | These are a few guidelines to keep in mind when opening pull requests, there is a GitHub template that reiterates most of the 42 | points described here. 43 | 44 | ### Commit hygiene 45 | 46 | We keep a strict 1-to-1 correspondance between commits and issues, as such our commit messages are formatted in the following 47 | fashion: 48 | 49 | `Add issues description (closes #1234)` 50 | 51 | for example: 52 | 53 | `Introduce an error ADT (closes #1234)` 54 | 55 | ### Writing tests and running tests 56 | 57 | Whenever necessary, it's good practice to add the corresponding unit tests to whichever feature you are working on. 58 | 59 | Then you can run `sbt test` to check they are working properly. 60 | 61 | ### Feedback cycle 62 | 63 | Reviews should happen fairly quickly during weekdays. If you feel your pull request has been forgotten, please ping one 64 | or more maintainers in the pull request. 65 | 66 | ### Getting your pull request merged 67 | 68 | If your pull request is fairly chunky, there might be a non-trivial delay between the moment the pull request is approved and 69 | the moment it gets merged. This is because your pull request will have been scheduled for a specific milestone which might or 70 | might not be actively worked on by a maintainer at the moment. 71 | 72 | ### Contributor license agreement 73 | 74 | We require outside contributors to sign a Contributor license agreement (or CLA) before we can merge their pull requests. 75 | You can find more information on the topic in [the dedicated wiki page](https://github.com/snowplow/snowplow/wiki/CLA). 76 | The @snowplowcla bot will guide you through the process. 77 | -------------------------------------------------------------------------------- /LICENSE-2.0.txt: -------------------------------------------------------------------------------- 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, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright 2012-2022 Snowplow Analytics Ltd. 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala Forex 2 | 3 | [![Build Status][ci-image]][ci] 4 | [![Maven Central][release-image]][releases] 5 | [![Coverage Status][coveralls-image]][coveralls] 6 | [![License][license-image]][license] 7 | 8 | ## 1. Introduction 9 | 10 | Scala Forex is a high-performance Scala library for performing exchange rate lookups and currency conversions, using [Joda-Money][joda-money]. 11 | 12 | It includes configurable LRU (Least Recently Used) caches to minimize calls to the API; this makes the library usable in high-volume environments such as Hadoop and Storm. 13 | 14 | Currently Scala Forex uses the [Open Exchange Rates API][oer-signup] to perform currency lookups. 15 | 16 | ## 2. Setup 17 | 18 | ### 2.1 OER Sign Up 19 | 20 | First [sign up][oer-signup] to Open Exchange Rates to get your App ID for API access. 21 | 22 | There are three types of accounts supported by OER API, Unlimited, Enterprise and Developer levels. See the [sign up][oer-signup] page for specific account descriptions. For Scala Forex, we recommend an Enterprise or Unlimited account, unless all of your conversions are to or from USD (see section 4.5 OER accounts for an explanation). For 10-minute rate updates, you will need an Unlimited account (other accounts are hourly). 23 | 24 | ### 2.2 Installation 25 | 26 | The latest version of Scala Forex is 3.0.0, which is cross-built against 2.12 & 2.13. 27 | 28 | If you're using SBT, add the following lines to your build file: 29 | 30 | ```scala 31 | libraryDependencies ++= Seq( 32 | "com.snowplowanalytics" %% "scala-forex" % "3.0.0" 33 | ) 34 | ``` 35 | 36 | Note the double percent (`%%`) between the group and artifactId. That'll ensure you get the right package for your Scala version. 37 | 38 | ## 3. Usage 39 | 40 | The Scala Forex library supports two types of usage: 41 | 42 | 1. Exchange rate lookups 43 | 2. Currency conversions 44 | 45 | Both usage types support live, near-live or historical (end-of-day) exchange rates. 46 | 47 | For all code samples below we are assuming the following imports: 48 | 49 | ```scala 50 | import org.joda.money.CurrencyUnit 51 | import com.snowplowanalytics.forex._ 52 | ``` 53 | 54 | ### 3.1 Configuration 55 | 56 | Scala Forex is configured via `ForexConfig` case class. Except `appId` and `accountLevel` fields, it provides 57 | some sensible defaults. 58 | 59 | ```scala 60 | case class ForexConfig( 61 | appId: String, 62 | accountLevel: AccountType, 63 | nowishCacheSize: Int = 13530, 64 | nowishSecs: Int = 300, 65 | eodCacheSize: Int = 405900, 66 | getNearestDay: EodRounding = EodRoundDown, 67 | baseCurrency: CurrencyUnit = CurrencyUnit.USD 68 | ) 69 | ``` 70 | 71 | To go through each in turn: 72 | 73 | 1. `appId` is the API key you get from OER. 74 | 75 | 2. `accountLevel` is the type of OER account you have. Possible values are `UnlimitedAccount`, `EnterpriseAccount`, and `DeveloperAccount`. 76 | 77 | 1. `nowishCacheSize` is the size configuration for near-live (nowish) lookup cache, it can be disabled by setting its value to 0. The key to nowish cache is a currency pair so the size of the cache equals to the number of pairs of currencies available. 78 | 79 | 2. `nowishSecs` is the time configuration for near-live lookup. A call to this cache will use the exchange rates stored in nowish cache if its time stamp is less than or equal to `nowishSecs` old. 80 | 81 | 3. `eodCacheSize` is the size configuration for end-of-day (eod) lookup cache, it can be disabled by setting its value to 0. The key to eod cache is a tuple of currency pair and time stamp, so the size of eod cache equals to the number of currency pairs times the days which the cache will remember the data for. 82 | 83 | 4. `getNearestDay` is the rounding configuration for latest eod (at) lookup. The lookup will be performed on the next day if the rounding mode is set to EodRoundUp, and on the previous day if EodRoundDown. 84 | 85 | 5. `baseCurrency` can be configured to different currencies by the users. 86 | 87 | For an explanation for the default values please see section **5.4 Explanation of defaults** below. 88 | 89 | ### 3.2 Rate lookup 90 | 91 | Unless specified otherwise, assume `forex` value is initialized as: 92 | 93 | ```scala 94 | val config: ForexConfig = ForexConfig("YOUR_API_KEY", DeveloperAccount) 95 | def fForex[F[_]: Sync]: F[Forex] = CreateForex[F].create(config) 96 | ``` 97 | 98 | `CreateForex[F].create` returns `F[Forex]` instead of `Forex`, because creation of the underlying 99 | caches is a side effect. You can `flatMap` over the result (or use a for-comprehension, as seen 100 | below). All examples below return `F[Either[OerResponseError, Money]]`, which means they are not 101 | executed. 102 | 103 | If you don't care about side effects, we also provide instance of `Forex` for `cats.Eval` and 104 | `cats.Id`: 105 | 106 | ```scala 107 | val evalForex: Eval[Forex] = CreateForex[Eval].create(config) 108 | val idForex: Forex = CreateForex[Id].create(config) 109 | ``` 110 | 111 | For distributed applications, such as Spark or Beam apps, where lazy values might be an issue, you may want to use the `Id` instance. 112 | 113 | #### 3.2.1 Live rate 114 | 115 | Look up a live rate _(no caching available)_: 116 | 117 | ```scala 118 | // USD => JPY 119 | val usd2jpyF: F[Either[OerResponseError, Money]] = for { 120 | fx <- fForex 121 | result <- fx.rate.to(CurrencyUnit.JPY).now 122 | } yield result 123 | 124 | // using Eval 125 | val usd2jpyE: Eval[Either[OerResponseError, Money]] = for { 126 | fx <- evalForex 127 | result <- fx.rate.to(CurrencyUnit.JPY).now 128 | } yield result 129 | 130 | // using Id 131 | val usd2jpyI: Either[OerResponseError, Money] = 132 | idForex.rate.to(CurrencyUnit.JPY).now 133 | ``` 134 | 135 | #### 3.2.2 Near-live rate 136 | 137 | Look up a near-live rate _(caching available)_: 138 | 139 | ```scala 140 | // JPY => GBP 141 | val jpy2gbp = for { 142 | fx <- fForex 143 | result <- fx.rate(CurrencyUnit.JPY).to(CurrencyUnit.GBP).nowish 144 | } yield result 145 | ``` 146 | 147 | #### 3.2.3 Near-live rate without cache 148 | 149 | Look up a near-live rate (_uses cache selectively_): 150 | 151 | ```scala 152 | // JPY => GBP 153 | val jpy2gbp = for { 154 | fx <- CreateForex[IO].create(ForexConfig("YOU_API_KEY", DeveloperAccount, nowishCacheSize = 0)) 155 | result <- fx.rate(CurrencyUnit.JPY).to(CurrencyUnit.GBP).nowish 156 | } yield result 157 | ``` 158 | 159 | #### 3.2.4 Latest-prior EOD rate 160 | 161 | Look up the latest EOD (end-of-date) rate prior to your event _(caching available)_: 162 | 163 | ```scala 164 | import java.time.{ZonedDateTime, ZoneId} 165 | 166 | // USD => JPY at the end of 13/03/2011 167 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 168 | val usd2jpy = for { 169 | fx <- fForex 170 | result <- fx.rate.to(CurrencyUnit.JPY).at(tradeDate) 171 | } yield result 172 | ``` 173 | 174 | #### 3.2.5 Latest-post EOD rate 175 | 176 | Look up the latest EOD (end-of-date) rate post to your event _(caching available)_: 177 | 178 | ```scala 179 | // USD => JPY at the end of 13/03/2011 180 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 181 | val usd2jpy = for { 182 | fx <- Forex.getClient[IO](ForexConfig("YOU_API_KEY", DeveloperAccount, getNearestDay = EodRoundUp)) 183 | result <- fx.rate.to(CurrencyUnit.JPY).at(tradeDate) 184 | } yield result 185 | ``` 186 | 187 | #### 3.2.6 Specific EOD rate 188 | 189 | Look up the EOD rate for a specific date _(caching available)_: 190 | 191 | ```scala 192 | // GBP => JPY at the end of 13/03/2011 193 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 0, 0, 0, 0, ZoneId.of("America/New_York")) 194 | val gbp2jpy = for { 195 | fx <- Forex.getClient[IO](ForexConfig("YOU_API_KEY", EnterpriseAccount, baseCurrency= CurrencyUnit.GBP)) 196 | result <- fx.rate.to(CurrencyUnit.JPY).eod(eodDate) 197 | } yield result 198 | ``` 199 | 200 | #### 3.2.7 Specific EOD rate without cache 201 | 202 | Look up the EOD rate for a specific date _(no caching)_: 203 | 204 | ```scala 205 | // GBP => JPY at the end of 13/03/2011 206 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 0, 0, 0, 0, ZoneId.of("America/New_York")) 207 | val gbp2jpy = for { 208 | fx <- Forex.getClient[IO](ForexConfig("YOU_API_KEY", EnterPriseAccount, 209 | baseCurrency = CurrencyUnit.GBP, eodCacheSize = 0)) 210 | result <- fx.rate.to(CurrencyUnit.JPY).eod(eodDate) 211 | } yield result 212 | ``` 213 | 214 | ### 3.3 Currency conversion 215 | 216 | #### 3.3.1 Live rate 217 | 218 | Conversion using the live exchange rate _(no caching available)_: 219 | 220 | ```scala 221 | // 9.99 USD => EUR 222 | val priceInEuros = for { 223 | fx <- forex 224 | result <- fx.convert(9.99).to(CurrencyUnit.EUR).now 225 | } yield result 226 | ``` 227 | 228 | #### 3.3.2 Near-live rate 229 | 230 | Conversion using a near-live exchange rate with 500 seconds `nowishSecs` _(caching available)_: 231 | 232 | ```scala 233 | // 9.99 GBP => EUR 234 | val priceInEuros = for { 235 | fx <- CreateForex[IO].create(ForexConfig("YOU_API_KEY", DeveloperAccount, nowishSecs = 500)) 236 | result <- fx.convert(9.99, CurrencyUnit.GBP).to(CurrencyUnit.EUR).nowish 237 | } yield result 238 | ``` 239 | 240 | #### 3.3.3 Near-live rate without cache 241 | 242 | Note that this will be a live rate conversion if cache is not available. 243 | Conversion using a live exchange rate with 500 seconds `nowishSecs`, 244 | this conversion will be done via HTTP request: 245 | 246 | ```scala 247 | // 9.99 GBP => EUR 248 | val priceInEuros = for { 249 | fx <- CreateForex[IO].create(ForexConfig("YOUR_API_KEY", DeveloperAccount, nowishSecs = 500, nowishCacheSize = 0)) 250 | result <- fx.convert(9.99, CurrencyUnit.GBP).to(CurrencyUnit.EUR).nowish 251 | } yield result 252 | ``` 253 | 254 | #### 3.3.4 Latest-prior EOD rate 255 | 256 | Conversion using the latest EOD (end-of-date) rate prior to your event _(caching available)_: 257 | 258 | ```scala 259 | // 10000 GBP => JPY at the end of 12/03/2011 260 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 261 | val tradeInYen = for { 262 | fx <- forex 263 | result <- fx.convert(10000, CurrencyUnit.GBP).to(CurrencyUnit.JPY).at(tradeDate) 264 | } yield result 265 | ``` 266 | 267 | #### 3.3.5 Latest-post EOD rate 268 | 269 | Lookup the latest EOD (end-of-date) rate following your event _(caching available)_: 270 | 271 | ```scala 272 | // 10000 GBP => JPY at the end of 13/03/2011 273 | val tradeDate = ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 274 | val usd2jpy = for { 275 | fx <- CreateForex[IO].create(ForexConfig("Your API key / app id", DeveloperAccount, getNearestDay = EodRoundUp)) 276 | result <- fx.convert(10000, CurrencyUnit.GBP).to(CurrencyUnit.JPY).at(tradeDate) 277 | } yield result 278 | ``` 279 | 280 | #### 3.3.6 Specific EOD rate 281 | 282 | Conversion using the EOD rate for a specific date _(caching available)_: 283 | 284 | ```scala 285 | // 10000 GBP => JPY at the end of 13/03/2011 286 | val eodDate = ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 287 | val tradeInYen = for { 288 | fx <- Forex.getClient[IO](ForexConfig("YOU_API_KEY", DeveloperAccount, baseCurrency = CurrencyUnit.GBP)) 289 | result <- fx.convert(10000).to(CurrencyUnit.JPY).eod(eodDate) 290 | } yield result 291 | ``` 292 | 293 | #### 3.3.7 Specific EOD rate without cache 294 | 295 | Conversion using the EOD rate for a specific date, _(no caching)_: 296 | 297 | ```scala 298 | // 10000 GBP => JPY at the end of 13/03/2011 299 | val eodDate = ZonedDateTime.of(2011, 3, 13, 0, 0, 0, 0, ZoneId.of("America/New_York")) 300 | val tradeInYen = for { 301 | fx <- Forex.getClient[IO](ForexConfig("YOU_API_KEY", DeveloperAccount, 302 | baseCurrency = CurrencyUnit.GBP, eodCacheSize = 0)) 303 | result <- fx.convert(10000).to(CurrencyUnit.JPY).eod(eodDate) 304 | } yield result 305 | ``` 306 | 307 | ### 3.4 Usage notes 308 | 309 | #### 3.4.1 LRU cache 310 | 311 | The `eodCacheSize` and `nowishCacheSize` values determine the maximum number of values to keep in the LRU cache, 312 | which the Client will check prior to making an API lookup. To disable either LRU cache, set its size to zero, 313 | i.e. `eodCacheSize = 0`. 314 | 315 | #### 3.4.2 From currency selection 316 | 317 | A default _"from currency"_ can be specified for all operations, using the `baseCurrency` argument to the `ForexConfig` object. 318 | If not specified, `baseCurrency` is set to USD by default. 319 | 320 | ## 4. Development 321 | 322 | ### 4.1 Running tests 323 | 324 | You **must** export your `OER_KEY` or else the tests will be skipped. To run the test suite locally: 325 | 326 | ``` 327 | $ export OER_KEY=<> 328 | $ git clone https://github.com/snowplow/scala-forex.git 329 | $ cd scala-forex 330 | $ sbt test 331 | ``` 332 | 333 | ## 5. Implementation details 334 | 335 | ### 5.1 End-of-day definition 336 | 337 | The end of today is 00:00 on the next day. 338 | 339 | ### 5.2 Exchange rate lookup 340 | 341 | When `.now` is specified, the **live** exchange rate available from Open Exchange Rates is used. 342 | 343 | When `.nowish` is specified, a **cached** version of the **live** exchange rate is used, if the timestamp of that exchange rate is less than or equal to `nowishSecs` (see above) ago. Otherwise a new lookup is performed. 344 | 345 | When `.at(...)` is specified, the **latest end-of-day rate prior** to the datetime is used by default. Or users can configure that Scala Forex "round up" to the end of the occurring day. 346 | 347 | When `.eod(...)` is specified, the end-of-day rate for the **specified day** is used. Any hour/minute/second/etc portion of the datetime is ignored. 348 | 349 | ### 5.3 LRU cache 350 | 351 | We recommend trying different LRU cache sizes to see what works best for you. 352 | 353 | Please note that the LRU cache implementation is **not** thread-safe. Switch it off if you are working with threads. 354 | 355 | ### 5.4 Explanation of defaults 356 | 357 | #### 5.4.1 `nowishCache` = (165 * 164 / 2) = 13530 358 | 359 | There are 165 currencies provided by the OER API, hence 165 * 164 pairs of currency combinations. 360 | The key in nowish cache is a tuple of source currency and target currency, and the nowish cache was implemented in a way such that a lookup from CurrencyA to CurrencyB or from CurrencyB to CurrencyA will use the same exchange rate, so we don't need to store both in the caches. Hence there are (165 * 164 / 2) pairs of currencies for nowish cache. 361 | 362 | #### 5.4.2 `eodCache` = (165 * 164 / 2) * 30 = 405900 363 | 364 | Assume the eod cache stores the rates to each pair of currencies for 1 month(i.e. 30 days). 365 | There are 165 * 164 / 2 pairs of currencies, hence (165 * 164 / 2) * 30 entries. 366 | 367 | #### 5.4.3 `nowishSecs` = 300 368 | 369 | Assume nowish cache stores data for 5 mins. 370 | 371 | #### 5.4.4 `getNearestDay` = EodRoundDown 372 | 373 | By convention, we are always interested in the exchange rates prior to the query date, hence EodRoundDown. 374 | 375 | #### 5.4.5 `baseCurrency` = USD 376 | 377 | We selected USD for the base currency because this is the OER default as well. 378 | 379 | ### 5.5 OER accounts 380 | 381 | With Open Exchange Rates' Unlimited and Enterprise accounts, Scala Forex can specify the base currency to use when looking up exchange rates; Developer-level accounts will always retrieve rates against USD, so a rate lookup from e.g. GBY to EUR will require two conversions (GBY -> USD -> EUR). For this reason, we recommend Unlimited and Enterprise-level accounts for slightly more accurate non-USD-related lookups. 382 | 383 | ## 6. Documentation 384 | 385 | The Scaladoc pages for this library can be found [here][scaladoc-pages]. 386 | 387 | ## 7. Copyright and license 388 | 389 | Scala Forex is copyright 2013-2022 Snowplow Analytics Ltd. 390 | 391 | Licensed under the [Apache License, Version 2.0][license] (the "License"); 392 | you may not use this software except in compliance with the License. 393 | 394 | Unless required by applicable law or agreed to in writing, software 395 | distributed under the License is distributed on an "AS IS" BASIS, 396 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 397 | See the License for the specific language governing permissions and 398 | limitations under the License. 399 | 400 | [oer-signup]: https://openexchangerates.org/signup?r=snowplow 401 | 402 | [joda-money]: http://www.joda.org/joda-money/ 403 | 404 | [coveralls]: https://coveralls.io/github/snowplow/scala-forex?branch=master 405 | [coveralls-image]: https://coveralls.io/repos/github/snowplow/scala-forex/badge.svg?branch=master 406 | 407 | [ci]: https://github.com/snowplow/scala-forex/actions?query=workflow%3ACI 408 | [ci-image]: https://github.com/snowplow/scala-forex/workflows/CI/badge.svg 409 | 410 | [releases]: https://maven-badges.herokuapp.com/maven-central/com.snowplowanalytics/scala-forex_2.12 411 | [release-image]: https://img.shields.io/maven-central/v/com.snowplowanalytics/scala-forex_2.12.svg 412 | 413 | [license-image]: http://img.shields.io/badge/license-Apache--2-blue.svg?style=flat 414 | [license]: http://www.apache.org/licenses/LICENSE-2.0 415 | 416 | [scaladoc-pages]: http://snowplow.github.io/scala-forex/ 417 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at 7 | * http://www.apache.org/licenses/LICENSE-2.0. 8 | * 9 | * Unless required by applicable law or agreed to in writing, 10 | * software distributed under the Apache License Version 2.0 is distributed on an 11 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the Apache License Version 2.0 for the specific language governing permissions and 13 | * limitations there under. 14 | */ 15 | 16 | lazy val root = project 17 | .in(file(".")) 18 | .settings( 19 | name := "scala-forex", 20 | description := "High-performance Scala library for performing currency conversions using Open Exchange Rates" 21 | ) 22 | .settings(BuildSettings.buildSettings) 23 | .settings(BuildSettings.publishSettings) 24 | .settings(BuildSettings.docsSettings) 25 | .settings( 26 | libraryDependencies ++= Seq( 27 | Dependencies.Libraries.jodaConvert, 28 | Dependencies.Libraries.jodaMoney, 29 | Dependencies.Libraries.catsEffect, 30 | Dependencies.Libraries.circeParser, 31 | Dependencies.Libraries.lruMap, 32 | Dependencies.Libraries.scalaj, 33 | Dependencies.Libraries.specs2Core, 34 | Dependencies.Libraries.specs2Mock 35 | ) 36 | ) 37 | .enablePlugins(SiteScaladocPlugin, PreprocessPlugin) 38 | -------------------------------------------------------------------------------- /project/BuildSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | 14 | // SBT 15 | import sbt._ 16 | import Keys._ 17 | import com.typesafe.sbt.site.SiteScaladocPlugin.autoImport.SiteScaladoc 18 | import sbtdynver.DynVerPlugin.autoImport.dynverVTagPrefix 19 | 20 | // Docs 21 | import com.typesafe.sbt.site.SitePlugin.autoImport.siteSubdirName 22 | import com.typesafe.sbt.site.SiteScaladocPlugin.autoImport._ 23 | 24 | object BuildSettings { 25 | 26 | lazy val javaCompilerOptions = Seq("-source", "11", "-target", "11") 27 | 28 | // Basic settings for our app 29 | lazy val buildSettings = Seq[Setting[_]]( 30 | organization := "com.snowplowanalytics", 31 | scalaVersion := "2.13.8", 32 | crossScalaVersions := Seq("2.12.15", "2.13.8"), 33 | javacOptions := javaCompilerOptions 34 | ) 35 | 36 | lazy val publishSettings = Seq[Setting[_]]( 37 | publishArtifact := true, 38 | Test / publishArtifact := false, 39 | pomIncludeRepository := { _ => false }, 40 | homepage := Some(url("http://snowplowanalytics.com")), 41 | licenses += ("Apache-2.0", url("http://www.apache.org/licenses/LICENSE-2.0.html")), 42 | ThisBuild / dynverVTagPrefix := false, // Otherwise git tags required to have v-prefix 43 | developers := List( 44 | Developer( 45 | "Snowplow Analytics Ltd", 46 | "Snowplow Analytics Ltd", 47 | "support@snowplowanalytics.com", 48 | url("https://snowplowanalytics.com") 49 | ) 50 | ) 51 | ) 52 | 53 | lazy val docsSettings = Seq( 54 | SiteScaladoc / siteSubdirName := s"${version.value}" 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | import sbt._ 14 | import Keys._ 15 | 16 | object Dependencies { 17 | 18 | object V { 19 | // Java 20 | val jodaMoney = "1.0.1" 21 | val jodaConvert = "2.2.0" 22 | 23 | // Scala 24 | val catsEffect = "3.3.5" 25 | val circe = "0.14.3" 26 | val lruMap = "0.6.0" 27 | val scalaj = "2.4.2" 28 | 29 | // Scala (test only) 30 | val specs2 = "4.13.2" 31 | } 32 | 33 | object Libraries { 34 | // Java 35 | val jodaMoney = "org.joda" % "joda-money" % V.jodaMoney 36 | val jodaConvert = "org.joda" % "joda-convert" % V.jodaConvert 37 | 38 | // Scala 39 | val catsEffect = "org.typelevel" %% "cats-effect" % V.catsEffect 40 | val circeParser = "io.circe" %% "circe-parser" % V.circe 41 | val lruMap = "com.snowplowanalytics" %% "scala-lru-map" % V.lruMap 42 | val scalaj = "org.scalaj" %% "scalaj-http" % V.scalaj 43 | 44 | // Scala (test only) 45 | val specs2Core = "org.specs2" %% "specs2-core" % V.specs2 % "test" 46 | val specs2Mock = "org.specs2" %% "specs2-mock" % V.specs2 % "test" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.6.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") 2 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3") 3 | addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.3.1") 4 | addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.3.2") 5 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.22") 6 | addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.7") 7 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/Forex.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.ZonedDateTime 16 | import java.time.temporal.ChronoUnit 17 | import java.math.{BigDecimal, RoundingMode} 18 | 19 | import scala.util.{Failure, Success, Try} 20 | 21 | import cats.Monad 22 | import cats.data.{EitherT, OptionT} 23 | import cats.implicits._ 24 | import org.joda.money._ 25 | 26 | import errors._ 27 | import model._ 28 | import com.snowplowanalytics.lrumap.CreateLruMap 29 | 30 | trait CreateForex[F[_]] { 31 | def create(config: ForexConfig): F[Forex[F]] 32 | } 33 | 34 | object CreateForex { 35 | def apply[F[_]](implicit ev: CreateForex[F]): CreateForex[F] = ev 36 | 37 | implicit def createForex[F[_]: Monad: Transport](implicit 38 | CLM1: CreateLruMap[F, NowishCacheKey, NowishCacheValue], 39 | CLM2: CreateLruMap[F, EodCacheKey, EodCacheValue] 40 | ): CreateForex[F] = 41 | (config: ForexConfig) => OerClient.getClient[F](config).map(client => Forex(config, client)) 42 | } 43 | 44 | /** Companion object to get Forex object */ 45 | object Forex { 46 | 47 | /** 48 | * Fields for calculating currency rates conversions. 49 | * Usually the currency rate has 6 decimal places 50 | */ 51 | protected[forex] val commonScale = 6 52 | 53 | /** Helper method to get the ratio of from:to in BigDecimal type */ 54 | protected[forex] def getForexRate( 55 | fromCurrIsBaseCurr: Boolean, 56 | baseOverFrom: BigDecimal, 57 | baseOverTo: BigDecimal 58 | ): BigDecimal = 59 | if (!fromCurrIsBaseCurr) { 60 | val fromOverBase = new BigDecimal(1).divide(baseOverFrom, Forex.commonScale, RoundingMode.HALF_EVEN) 61 | fromOverBase.multiply(baseOverTo) 62 | } else 63 | baseOverTo 64 | } 65 | 66 | /** 67 | * Starts building the fluent interface for currency look-up and conversion, 68 | * Forex class has methods rate and convert, which set the source currency to 69 | * the currency we are interested in. 70 | * They return ForexLookupTo object which is then passed to methods 71 | * in ForexLookupTo class in the fluent interface. 72 | * @param config A configurator for Forex object 73 | * @param client Passed down client that does actual work 74 | */ 75 | final case class Forex[F[_]](config: ForexConfig, client: OerClient[F]) { 76 | 77 | def rate: ForexLookupTo[F] = convert(1d, config.baseCurrency) 78 | 79 | /** 80 | * Starts building a fluent interface, performs currency look up from *source* currency. 81 | * (The target currency will be supplied to the ForexLookupTo later). 82 | * @param currency Source currency 83 | * @return ForexLookupTo object which is the part of the fluent interface 84 | */ 85 | def rate(currency: CurrencyUnit): ForexLookupTo[F] = convert(1d, currency) 86 | 87 | /** 88 | * Starts building a currency conversion for the supplied amount, using the currency specified in config 89 | * @param amount The amount of currency to be converted 90 | * @return a ForexLookupTo, part of the currency conversion fluent interface 91 | */ 92 | def convert(amount: Double): ForexLookupTo[F] = convert(amount, config.baseCurrency) 93 | 94 | /** 95 | * Starts building a currency conversion from the supplied currency, for the supplied amount. 96 | * @param amount The amount of currency to be converted 97 | * @param currency CurrencyUnit to convert from 98 | * @return a ForexLookupTo, part of the currency conversion fluent interface. 99 | */ 100 | def convert(amount: Double, currency: CurrencyUnit): ForexLookupTo[F] = 101 | ForexLookupTo(amount, currency, config, client) 102 | } 103 | 104 | /** 105 | * ForexLookupTo is the second part of the fluent interface, 106 | * which passes parameters taken from Forex class to the next stage in the fluent interface 107 | * and sets the target currency for the lookup/conversion. 108 | * Method in this class returns ForexLookupWhen object which will be passed to the next stage 109 | * in the fluent interface. 110 | * @param conversionAmount The amount of money to be converted, 111 | * it is set to 1 unit for look up operation. 112 | * @param fromCurr The source currency 113 | * @param config Forex config 114 | * @param client Passed down client that does actual work 115 | */ 116 | final case class ForexLookupTo[F[_]]( 117 | conversionAmount: Double, 118 | fromCurr: CurrencyUnit, 119 | config: ForexConfig, 120 | client: OerClient[F] 121 | ) { 122 | 123 | /** 124 | * Continue building the target currency to the desired one 125 | * @param toCurr Target currency 126 | * @return ForexLookupWhen object which is final part of the fluent interface 127 | */ 128 | def to(toCurr: CurrencyUnit): ForexLookupWhen[F] = 129 | ForexLookupWhen(conversionAmount, fromCurr, toCurr, config, client) 130 | } 131 | 132 | /** 133 | * ForexLookupWhen is the final part of the fluent interface, 134 | * methods in this class are the final stage of currency lookup/conversion 135 | * @param conversionAmount The amount of money to be converted, it is set to 1 for lookup operation 136 | * @param fromCurr The source currency 137 | * @param toCurr The target currency 138 | * @param config Forex config 139 | * @param client Passed down client that does actual work 140 | */ 141 | final case class ForexLookupWhen[F[_]]( 142 | conversionAmount: Double, 143 | fromCurr: CurrencyUnit, 144 | toCurr: CurrencyUnit, 145 | config: ForexConfig, 146 | client: OerClient[F] 147 | ) { 148 | // convert `conversionAmt` into BigDecimal representation for its later usage in BigMoney 149 | val conversionAmt = new BigDecimal(conversionAmount) 150 | 151 | /** 152 | * Performs live currency lookup/conversion 153 | * @return Money representation in target currency or OerResponseError object if API request 154 | * failed 155 | */ 156 | def now(implicit M: Monad[F], C: ZonedClock[F]): F[Either[OerResponseError, Money]] = { 157 | val product = for { 158 | fromRate <- EitherT(client.getLiveCurrencyValue(fromCurr)) 159 | toRate <- EitherT(client.getLiveCurrencyValue(toCurr)) 160 | } yield (fromRate, toRate) 161 | 162 | product.flatMapF { 163 | case (fromRate, toRate) => 164 | val fromCurrIsBaseCurr = fromCurr == config.baseCurrency 165 | val rate = Forex.getForexRate(fromCurrIsBaseCurr, fromRate, toRate) 166 | // Note that if `fromCurr` is not the same as the base currency, 167 | // then we need to add the pair into the cache in particular, 168 | // because only were added earlier 169 | client 170 | .nowishCache 171 | .filter(_ => fromCurr != config.baseCurrency) 172 | .traverse(cache => C.currentTime.map(dateTime => (cache, dateTime))) 173 | .flatMap(_.traverse { case (cache, timeStamp) => cache.put((fromCurr, toCurr), (timeStamp, rate)) }) 174 | .map(_ => returnMoneyOrJodaError(rate)) 175 | }.value 176 | } 177 | 178 | /** 179 | * Performs near-live currency lookup/conversion. 180 | * A cached version of the live exchange rate is used 181 | * if a cache exists and the timestamp of that exchange rate is less than or equal to "nowishSecs" old. 182 | * Otherwise a new lookup is performed. 183 | * @return Money representation in target currency or OerResponseError object if API request 184 | * failed 185 | */ 186 | def nowish(implicit M: Monad[F], C: ZonedClock[F]): F[Either[OerResponseError, Money]] = 187 | (for { 188 | (time, rate) <- lookupNowishCache(fromCurr, toCurr) 189 | nowishTime <- OptionT.liftF(C.currentTime.map(_.minusSeconds(config.nowishSecs.toLong))) 190 | if nowishTime.isBefore(time) || nowishTime.equals(time) 191 | res = returnMoneyOrJodaError(rate) 192 | } yield res).getOrElseF(now) 193 | 194 | private def lookupNowishCache( 195 | fromCurr: CurrencyUnit, 196 | toCurr: CurrencyUnit 197 | )(implicit M: Monad[F]): OptionT[F, NowishCacheValue] = { 198 | val oneWay = OptionT(client.nowishCache.flatTraverse(cache => cache.get((fromCurr, toCurr)))) 199 | val otherWay = OptionT(client.nowishCache.flatTraverse(cache => cache.get((toCurr, fromCurr)))).map { 200 | case (time, rate) => (time, inverseRate(rate)) 201 | } 202 | 203 | oneWay.orElse(otherWay) 204 | } 205 | 206 | /** 207 | * Gets the latest end-of-day rate prior to the event or post to the event 208 | * @return Money representation in target currency or OerResponseError object if API request 209 | * failed 210 | */ 211 | def at(tradeDate: ZonedDateTime)(implicit M: Monad[F]): F[Either[OerResponseError, Money]] = { 212 | val latestEod = 213 | if (config.getNearestDay == EodRoundUp) 214 | tradeDate.truncatedTo(ChronoUnit.DAYS).plusDays(1) 215 | else 216 | tradeDate.truncatedTo(ChronoUnit.DAYS) 217 | eod(latestEod) 218 | } 219 | 220 | /** 221 | * Gets the end-of-day rate for the specified date 222 | * @return Money representation in target currency or OerResponseError object if API request 223 | * failed 224 | */ 225 | def eod(eodDate: ZonedDateTime)(implicit M: Monad[F]): F[Either[OerResponseError, Money]] = 226 | OptionT 227 | .fromOption[F](client.eodCache) 228 | .flatMap(_ => lookupEodCache(fromCurr, toCurr, eodDate)) 229 | .map(rate => returnMoneyOrJodaError(rate)) 230 | .getOrElseF(getHistoricalRate(eodDate)) 231 | 232 | private def lookupEodCache( 233 | fromCurr: CurrencyUnit, 234 | toCurr: CurrencyUnit, 235 | eodDate: ZonedDateTime 236 | )(implicit M: Monad[F]): OptionT[F, BigDecimal] = { 237 | val oneWay = OptionT(client.eodCache.flatTraverse(cache => cache.get((fromCurr, toCurr, eodDate)))) 238 | val otherWay = 239 | OptionT(client.eodCache.flatTraverse(cache => cache.get((toCurr, fromCurr, eodDate)))).map(inverseRate) 240 | 241 | oneWay.orElse(otherWay) 242 | } 243 | 244 | private def inverseRate(rate: BigDecimal): BigDecimal = 245 | new BigDecimal(1).divide(rate, Forex.commonScale, RoundingMode.HALF_EVEN) 246 | 247 | /** 248 | * Helper method to get the historical forex rate between two currencies on a given date, 249 | * @return Money in target currency representation or error message if the date given is invalid 250 | */ 251 | private def getHistoricalRate(date: ZonedDateTime)(implicit M: Monad[F]): F[Either[OerResponseError, Money]] = { 252 | val fromF = EitherT(client.getHistoricalCurrencyValue(fromCurr, date)) 253 | val toF = EitherT(client.getHistoricalCurrencyValue(toCurr, date)) 254 | 255 | fromF 256 | .product(toF) 257 | .flatMapF { 258 | case (fromRate, toRate) => 259 | // API request succeeds 260 | val fromCurrIsBaseCurr = fromCurr == config.baseCurrency 261 | val rate = Forex.getForexRate(fromCurrIsBaseCurr, fromRate, toRate) 262 | // Note that if `fromCurr` is not the same as the base currency, 263 | // then we need to add the pair into the cache in particular, 264 | // because only were added earlier 265 | client 266 | .eodCache 267 | .filter(_ => fromCurr != config.baseCurrency) 268 | .traverse_(cache => cache.put((fromCurr, toCurr, date), rate)) 269 | .map(_ => returnMoneyOrJodaError(rate)) 270 | } 271 | .value 272 | } 273 | 274 | /** 275 | * This method is called when the forex rate has been found in the API 276 | * @param rate The forex rate between source and target currency 277 | * @return Money representation in target currency if both currencies are supported by Joda Money 278 | * or OerResponseError with an error message containing the forex rate between the two 279 | * currencies 280 | * if either of the currency is not supported by Joda Money 281 | */ 282 | private def returnMoneyOrJodaError(rate: BigDecimal): Either[OerResponseError, Money] = { 283 | val moneyInSourceCurrency = BigMoney.of(fromCurr, conversionAmt) 284 | 285 | val moneyTry = 286 | if (fromCurr == toCurr) 287 | Try(moneyInSourceCurrency.toMoney(RoundingMode.HALF_EVEN)) 288 | else 289 | Try(moneyInSourceCurrency.convertedTo(toCurr, rate).toMoney(RoundingMode.HALF_EVEN)) 290 | 291 | moneyTry match { 292 | case Success(m) => Right(m) 293 | case Failure(e) => Left(OerResponseError(e.getMessage, OtherErrors)) 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/OerClient.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.{LocalDateTime, ZoneId, ZonedDateTime} 16 | import java.math.{BigDecimal => JBigDecimal} 17 | 18 | import cats.Monad 19 | import cats.implicits._ 20 | import cats.data.EitherT 21 | import org.joda.money.CurrencyUnit 22 | 23 | import com.snowplowanalytics.lrumap.CreateLruMap 24 | import errors._ 25 | import model._ 26 | import responses._ 27 | 28 | /** 29 | * Implements Json for Open Exchange Rates(http://openexchangerates.org) 30 | * @param config - a configurator for Forex object 31 | * @param nowishCache - user defined nowishCache 32 | * @param eodCache - user defined eodCache 33 | */ 34 | final case class OerClient[F[_]: Monad]( 35 | config: ForexConfig, 36 | nowishCache: Option[NowishCache[F]] = None, 37 | eodCache: Option[EodCache[F]] = None 38 | )(implicit T: Transport[F]) { 39 | 40 | private val endpoint = "openexchangerates.org/api/" 41 | 42 | /** Sets the base currency in the url 43 | * according to the API, only Unlimited and Enterprise accounts 44 | * are allowed to set the base currency in the HTTP URL 45 | */ 46 | private val base = config.accountLevel match { 47 | case UnlimitedAccount => "&base=" + config.baseCurrency 48 | case EnterpriseAccount => "&base=" + config.baseCurrency 49 | case DeveloperAccount => "" 50 | } 51 | 52 | /** 53 | * The constant that will hold the URL for 54 | * a live exchange rate lookup from OER 55 | */ 56 | private val latest = "latest.json?app_id=" + config.appId + base 57 | 58 | /** The earliest date OER service is availble */ 59 | private val oerDataFrom = 60 | ZonedDateTime.of(LocalDateTime.of(1999, 1, 1, 0, 0), ZoneId.systemDefault) 61 | 62 | /** 63 | * Gets live currency value for the desired currency, 64 | * silently drop the currency types which Joda money does not support. 65 | * If cache exists, update nowishCache when an API request has been done, 66 | * else just return the forex rate 67 | * @param currency - The desired currency we want to look up from the API 68 | * @return result returned from API 69 | */ 70 | def getLiveCurrencyValue(currency: CurrencyUnit): F[ApiRequestResult] = { 71 | val action = for { 72 | response <- EitherT(T.receive(endpoint, latest)) 73 | liveCurrency <- EitherT(extractLiveCurrency(response, currency)) 74 | } yield liveCurrency 75 | action.value 76 | } 77 | 78 | private def extractLiveCurrency( 79 | response: OerResponse, 80 | currency: CurrencyUnit 81 | ): F[ApiRequestResult] = { 82 | val cacheActions = nowishCache.traverse { cache => 83 | config.accountLevel match { 84 | // If the user is using Developer account, 85 | // then base currency returned from the API is USD. 86 | // To store user-defined base currency into the cache, 87 | // we need to convert the forex rate between target currency and USD 88 | // to target currency and user-defined base currency 89 | case DeveloperAccount => 90 | val usdOverBase = response.rates(config.baseCurrency) 91 | response.rates.toList.traverse_ { 92 | case (currentCurrency, usdOverCurr) => 93 | val keyPair = (config.baseCurrency, currentCurrency) 94 | // flag indicating if the base currency has been set to USD 95 | val fromCurrIsBaseCurr = config.baseCurrency == CurrencyUnit.USD 96 | val baseOverCurr = 97 | Forex.getForexRate(fromCurrIsBaseCurr, usdOverBase.bigDecimal, usdOverCurr.bigDecimal) 98 | val valPair = (ZonedDateTime.now, baseOverCurr) 99 | cache.put(keyPair, valPair) 100 | } 101 | // For Enterprise and Unlimited users, OER allows them to configure the base currencies. 102 | // So the exchange rate returned from the API is between target currency and the base 103 | // currency they defined. 104 | case _ => 105 | response.rates.toList.traverse_ { 106 | case (currentCurrency, currencyValue) => 107 | val keyPair = (config.baseCurrency, currentCurrency) 108 | val valPair = (ZonedDateTime.now, currencyValue.bigDecimal) 109 | cache.put(keyPair, valPair) 110 | } 111 | } 112 | } 113 | cacheActions.map { _ => 114 | response 115 | .rates 116 | .get(currency) 117 | .map(_.bigDecimal) 118 | .toRight(OerResponseError("Currency not found in the API, invalid currency ", IllegalCurrency)) 119 | } 120 | } 121 | 122 | /** 123 | * Builds the historical link for the URI according to the date 124 | * @param date - The historical date for the currency look up, 125 | * which should be the same as date argument in the getHistoricalCurrencyValue method below 126 | * @return the link in string format 127 | */ 128 | private def buildHistoricalLink(date: ZonedDateTime): String = 129 | f"historical/${date.getYear}%04d-${date.getMonthValue}%02d-${date.getDayOfMonth}%02d.json?app_id=${config.appId}" + base 130 | 131 | /** 132 | * Gets historical forex rate for the given currency and date 133 | * return error message if the date is invalid 134 | * silently drop the currency types which Joda money does not support 135 | * if cache exists, update the eodCache when an API request has been done, 136 | * else just return the look up result 137 | * @param currency - The desired currency we want to look up from the API 138 | * @param date - The specific date we want to look up on 139 | * @return result returned from API 140 | */ 141 | def getHistoricalCurrencyValue( 142 | currency: CurrencyUnit, 143 | date: ZonedDateTime 144 | ): F[ApiRequestResult] = 145 | if (date.isBefore(oerDataFrom) || date.isAfter(ZonedDateTime.now)) 146 | OerResponseError(s"Exchange rate unavailable on the date [$date]", ResourcesNotAvailable) 147 | .asLeft[JBigDecimal] 148 | .pure[F] 149 | else { 150 | val historicalLink = buildHistoricalLink(date) 151 | val action = for { 152 | response <- EitherT(T.receive(endpoint, historicalLink)) 153 | currency <- EitherT(extractHistoricalCurrency(response, currency, date)) 154 | } yield currency 155 | 156 | action.value 157 | } 158 | 159 | private def extractHistoricalCurrency( 160 | response: OerResponse, 161 | currency: CurrencyUnit, 162 | date: ZonedDateTime 163 | ): F[ApiRequestResult] = { 164 | val cacheAction = eodCache.traverse { cache => 165 | config.accountLevel match { 166 | // If the user is using Developer account, 167 | // then base currency returned from the API is USD. 168 | // To store user-defined base currency into the cache, 169 | // we need to convert the forex rate between target currency and USD 170 | // to target currency and user-defined base currency 171 | case DeveloperAccount => 172 | val usdOverBase = response.rates(config.baseCurrency) 173 | response.rates.toList.traverse_ { 174 | case (currentCurrency, usdOverCurr) => 175 | val keyPair = (config.baseCurrency, currentCurrency, date) 176 | val fromCurrIsBaseCurr = config.baseCurrency == CurrencyUnit.USD 177 | cache.put( 178 | keyPair, 179 | Forex.getForexRate( 180 | fromCurrIsBaseCurr, 181 | usdOverBase.bigDecimal, 182 | usdOverCurr.bigDecimal 183 | ) 184 | ) 185 | } 186 | // For Enterprise and Unlimited users, OER allows them to configure the base currencies. 187 | // So the exchange rate returned from the API is between target currency and the base 188 | // currency they defined. 189 | case _ => 190 | response.rates.toList.traverse_ { 191 | case (currentCurrency, currencyValue) => 192 | val keyPair = (config.baseCurrency, currentCurrency, date) 193 | cache.put(keyPair, currencyValue.bigDecimal) 194 | } 195 | } 196 | } 197 | 198 | cacheAction.map { _ => 199 | response 200 | .rates 201 | .get(currency) 202 | .map(_.bigDecimal) 203 | .toRight(OerResponseError(s"Currency not found in the API, invalid currency $currency", IllegalCurrency)) 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * Companion object for ForexClient class 210 | * This class has one method for getting forex clients 211 | * but for now there is only one client since we are only using OER 212 | */ 213 | object OerClient { 214 | 215 | /** Creates a client with a cache and sensible default ForexConfig */ 216 | def getClient[F[_]: Monad: Transport]( 217 | appId: String, 218 | accountLevel: AccountType 219 | )(implicit 220 | CLM1: CreateLruMap[F, NowishCacheKey, NowishCacheValue], 221 | CLM2: CreateLruMap[F, EodCacheKey, EodCacheValue] 222 | ): F[OerClient[F]] = 223 | getClient[F](ForexConfig(appId = appId, accountLevel = accountLevel)) 224 | 225 | /** Getter for clients, creating the caches as defined in the config */ 226 | def getClient[F[_]: Monad: Transport]( 227 | config: ForexConfig 228 | )(implicit 229 | CLM1: CreateLruMap[F, NowishCacheKey, NowishCacheValue], 230 | CLM2: CreateLruMap[F, EodCacheKey, EodCacheValue] 231 | ): F[OerClient[F]] = { 232 | val nowishCacheF = 233 | if (config.nowishCacheSize > 0) 234 | CLM1.create(config.nowishCacheSize).map(_.some) 235 | else 236 | Monad[F].pure(Option.empty[NowishCache[F]]) 237 | 238 | val eodCacheF = 239 | if (config.eodCacheSize > 0) 240 | CLM2.create(config.eodCacheSize).map(_.some) 241 | else 242 | Monad[F].pure(Option.empty[EodCache[F]]) 243 | 244 | (nowishCacheF, eodCacheF).mapN { 245 | case (nowish, eod) => 246 | new OerClient[F](config, nowishCache = nowish, eodCache = eod) 247 | } 248 | } 249 | 250 | def getClient[F[_]: Monad: Transport]( 251 | config: ForexConfig, 252 | nowishCache: Option[NowishCache[F]], 253 | eodCache: Option[EodCache[F]] 254 | ): OerClient[F] = new OerClient[F](config, nowishCache, eodCache) 255 | } 256 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/Transport.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import cats.{Eval, Id} 16 | import cats.effect.Sync 17 | import cats.syntax.either._ 18 | import io.circe.Decoder 19 | import io.circe.parser._ 20 | import scalaj.http._ 21 | 22 | import errors._ 23 | import responses._ 24 | 25 | trait Transport[F[_]] { 26 | 27 | /** 28 | * Main client logic for Request => Response function, 29 | * where Response is wrapped in tparam `F` 30 | * @param endpoint endpoint to interrogate 31 | * @param path path to request 32 | * @return extracted either error or exchanged rate wrapped in `F` 33 | */ 34 | def receive(endpoint: String, path: String): F[Either[OerResponseError, OerResponse]] 35 | 36 | } 37 | 38 | object Transport { 39 | 40 | /** 41 | * Http Transport leveraging cats-effect's Sync. 42 | * @return a Sync Transport 43 | */ 44 | implicit def httpTransport[F[_]: Sync]: Transport[F] = 45 | new Transport[F] { 46 | def receive(endpoint: String, path: String): F[Either[OerResponseError, OerResponse]] = 47 | Sync[F].delay(buildRequest(endpoint, path)) 48 | } 49 | 50 | /** 51 | * Eval http Transport to use in cases where you have to do side-effects (e.g. spark or beam). 52 | * @return an Eval Transport 53 | */ 54 | implicit def evalHttpTransport: Transport[Eval] = 55 | new Transport[Eval] { 56 | def receive(endpoint: String, path: String): Eval[Either[OerResponseError, OerResponse]] = 57 | Eval.later { 58 | buildRequest(endpoint, path) 59 | } 60 | } 61 | 62 | /** 63 | * Id http Transport to use in cases where you don't care about side-effects. 64 | * @return an Id Transport 65 | */ 66 | implicit def idHttpTransport: Transport[Id] = 67 | new Transport[Id] { 68 | def receive(endpoint: String, path: String): Id[Either[OerResponseError, OerResponse]] = 69 | buildRequest(endpoint, path) 70 | } 71 | 72 | implicit def eitherDecoder: Decoder[Either[OerResponseError, OerResponse]] = 73 | implicitly[Decoder[OerResponseError]].either(implicitly[Decoder[OerResponse]]) 74 | 75 | private def buildRequest( 76 | endpoint: String, 77 | path: String 78 | ): Either[OerResponseError, OerResponse] = 79 | for { 80 | response <- Http("http://" + endpoint + path).asString.body.asRight 81 | parsed <- 82 | parse(response).leftMap(e => OerResponseError(s"OER response is not JSON: ${e.getMessage}", OtherErrors)) 83 | decoded <- 84 | parsed 85 | .as[Either[OerResponseError, OerResponse]] 86 | .leftMap(_ => OerResponseError(s"OER response couldn't be decoded", OtherErrors)) 87 | res <- decoded 88 | } yield res 89 | } 90 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/ZonedClock.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.ZonedDateTime 16 | 17 | import cats.{Eval, Id} 18 | import cats.effect.Sync 19 | 20 | trait ZonedClock[F[_]] { 21 | def currentTime: F[ZonedDateTime] 22 | } 23 | 24 | object ZonedClock { 25 | implicit def zonedClock[F[_]: Sync]: ZonedClock[F] = 26 | new ZonedClock[F] { 27 | def currentTime: F[ZonedDateTime] = Sync[F].delay(ZonedDateTime.now) 28 | } 29 | 30 | implicit def evalZonedClock: ZonedClock[Eval] = 31 | new ZonedClock[Eval] { 32 | def currentTime: Eval[ZonedDateTime] = Eval.later(ZonedDateTime.now) 33 | } 34 | 35 | implicit def idZonedClock: ZonedClock[Id] = 36 | new ZonedClock[Id] { 37 | def currentTime: Id[ZonedDateTime] = ZonedDateTime.now 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/errors.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import io.circe.Decoder 16 | 17 | object errors { 18 | 19 | /** 20 | * OER error states from the HTTP requests 21 | * @param errorMessage - error message from OER API 22 | * @param errorType - specific error type 23 | */ 24 | final case class OerResponseError(errorMessage: String, errorType: OerError) 25 | 26 | implicit val oerResponseErrorDecoder: Decoder[OerResponseError] = 27 | Decoder.decodeString.prepare(_.downField("message")).map(OerResponseError(_, OtherErrors)) 28 | 29 | /** 30 | * User defined error types 31 | */ 32 | sealed trait OerError 33 | 34 | /** 35 | * Caused by invalid DateTime argument 36 | * i.e. either earlier than the earliest date OER service is available or later than currenct time 37 | */ 38 | case object ResourcesNotAvailable extends OerError 39 | 40 | /** 41 | * Currency not supported by API or 42 | * Joda Money or both 43 | */ 44 | case object IllegalCurrency extends OerError 45 | 46 | /** 47 | * Other possible error types e.g. 48 | * access permissions 49 | */ 50 | case object OtherErrors extends OerError 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/model.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import org.joda.money.CurrencyUnit 16 | 17 | object model { 18 | 19 | /** User defined type for getNearestDay flag */ 20 | sealed trait EodRounding 21 | 22 | /** Round to previous day */ 23 | case object EodRoundDown extends EodRounding 24 | 25 | /** Round to next day */ 26 | case object EodRoundUp extends EodRounding 27 | 28 | /** 29 | * There are three types of accounts supported by OER API. 30 | * For scala-forex library, the main difference between Unlimited/Enterprise 31 | * and Developer users is that users with Unlimited/Enterprise accounts 32 | * can use the base currency for API requests, but this library will provide 33 | * automatic conversions between OER default base currencies(USD) 34 | * and user-defined base currencies. However this will increase calls to the API 35 | * and will slow down the performance. 36 | */ 37 | sealed trait AccountType 38 | case object DeveloperAccount extends AccountType 39 | case object EnterpriseAccount extends AccountType 40 | case object UnlimitedAccount extends AccountType 41 | 42 | /** 43 | * Configure class for Forex object 44 | * @param appId Key for the api 45 | * @param accountLevel Type of the registered account 46 | * @param endpoint API endpoint 47 | * @param nowishCacheSize Cache for nowish look up 48 | * @param nowishSecs Time range for nowish look up 49 | * @param eodCacheSize Cache for historical lookup 50 | * @param getNearestDay Flag for deciding whether to get the exchange rate on closer day or previous day 51 | * @param baseCurrency Base currency is set to be USD by default if configurableBase flag is false, otherwise it is user-defined 52 | */ 53 | case class ForexConfig( 54 | // Register an account on https://openexchangerates.org to obtain your unique key 55 | appId: String, 56 | accountLevel: AccountType, 57 | // nowishCacheSize = (165 * 164 / 2) = 13530. 58 | // There are 165 currencies in total, the combinations of a currency pair 59 | // has 165 * (165 - 1) possibilities. (X,Y) is the same as (Y,X) hence 165 * 164 / 2 60 | nowishCacheSize: Int = 13530, 61 | //5 mins by default 62 | nowishSecs: Int = 300, 63 | // 165 * 164 / 2 * 30 = 405900, assuming the cache stores data within a month 64 | eodCacheSize: Int = 405900, 65 | getNearestDay: EodRounding = EodRoundDown, 66 | baseCurrency: CurrencyUnit = CurrencyUnit.USD 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics 14 | 15 | import java.time.ZonedDateTime 16 | import java.math.BigDecimal 17 | 18 | import org.joda.money.CurrencyUnit 19 | 20 | import com.snowplowanalytics.lrumap.LruMap 21 | import forex.errors._ 22 | 23 | package object forex { 24 | 25 | /** 26 | * The key and value for each cache entry 27 | */ 28 | type NowishCacheKey = (CurrencyUnit, CurrencyUnit) // source currency , target currency 29 | type NowishCacheValue = (ZonedDateTime, BigDecimal) // timestamp, exchange rate 30 | 31 | type EodCacheKey = (CurrencyUnit, CurrencyUnit, ZonedDateTime) // source currency, target currency, timestamp 32 | type EodCacheValue = BigDecimal // exchange rate 33 | 34 | // The API request either returns exchange rates in BigDecimal representation 35 | // or OerResponseError if the request failed 36 | type ApiRequestResult = Either[OerResponseError, BigDecimal] 37 | 38 | /** 39 | * The two LRU caches we use 40 | */ 41 | type NowishCache[F[_]] = LruMap[F, NowishCacheKey, NowishCacheValue] 42 | type EodCache[F[_]] = LruMap[F, EodCacheKey, EodCacheValue] 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/com.snowplowanalytics/forex/responses.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import scala.util.Try 16 | 17 | import cats.instances.list._ 18 | import cats.syntax.functorFilter._ 19 | import io.circe._ 20 | import org.joda.money.CurrencyUnit 21 | 22 | object responses { 23 | final case class OerResponse(rates: Map[CurrencyUnit, BigDecimal]) 24 | 25 | // Encoder ignores Currencies that are not parsable by CurrencyUnit 26 | implicit val oerResponseDecoder: Decoder[OerResponse] = new Decoder[OerResponse] { 27 | override def apply(c: HCursor): Decoder.Result[OerResponse] = 28 | c.downField("rates").as[Map[String, BigDecimal]].map { map => 29 | OerResponse( 30 | map 31 | .toList 32 | .mapFilter { 33 | case (key, value) => Try(CurrencyUnit.of(key)).toOption.map(_ -> value) 34 | } 35 | .toMap 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/site-preprocess/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Project Documentation 6 | 15 | 16 | 17 | Go to the project documentation 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/ForexAtSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.{ZoneId, ZonedDateTime} 16 | 17 | import cats.effect.IO 18 | import cats.effect.unsafe.implicits.global 19 | import org.joda.money.{CurrencyUnit, Money} 20 | import org.specs2.mutable.Specification 21 | 22 | import model._ 23 | 24 | /** 25 | * Testing method for getting the latest end-of-day rate 26 | * prior to the datetime or the day after according to the user's setting 27 | */ 28 | class ForexAtSpec extends Specification { 29 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 30 | 31 | val key = sys.env.getOrElse("OER_KEY", "") 32 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 33 | val ioFxWithBaseGBP = 34 | CreateForex[IO].create(ForexConfig(key, EnterpriseAccount, baseCurrency = CurrencyUnit.GBP)) 35 | 36 | val tradeDate = 37 | ZonedDateTime.of(2011, 3, 13, 11, 39, 27, 567, ZoneId.of("America/New_York")) 38 | 39 | /** GBP->CAD with USD as baseCurrency */ 40 | "GBP to CAD with USD as base currency returning latest eod rate" should { 41 | "be > 0" in { 42 | val ioGbpToCadWithBaseUsd = 43 | ioFx.flatMap(_.rate(CurrencyUnit.GBP).to(CurrencyUnit.CAD).at(tradeDate)) 44 | ioGbpToCadWithBaseUsd.unsafeRunSync() must beRight((m: Money) => m.isPositive) 45 | } 46 | } 47 | 48 | /** GBP-> CAD with GBP as base currency */ 49 | "GBP to CAD with GBP as base currency returning latest eod" should { 50 | "be > 0" in { 51 | val ioGbpToCadWithBaseGbp = ioFxWithBaseGBP.flatMap(_.rate.to(CurrencyUnit.CAD).at(tradeDate)) 52 | ioGbpToCadWithBaseGbp.unsafeRunSync() must beRight((m: Money) => m.isPositive) 53 | } 54 | } 55 | 56 | /** CNY -> GBP with USD as base currency */ 57 | "CNY to GBP with USD as base currency returning latest eod rate" should { 58 | "be > 0" in { 59 | val ioCnyOverGbpHistorical = 60 | ioFx.flatMap(_.rate(CurrencyUnit.of("CNY")).to(CurrencyUnit.GBP).at(tradeDate)) 61 | ioCnyOverGbpHistorical.unsafeRunSync() must beRight((m: Money) => m.isPositive) 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/ForexEodSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.ZonedDateTime 16 | import java.time.format.DateTimeFormatter 17 | 18 | import cats.effect.IO 19 | import cats.effect.unsafe.implicits.global 20 | import org.joda.money.{CurrencyUnit, Money} 21 | import org.specs2.mutable.Specification 22 | import org.specs2.matcher.DataTables 23 | 24 | import model._ 25 | 26 | /** 27 | * Testing method for getting the end-of-date exchange rate 28 | * since historical forex rate is fixed, the actual look up result should be 29 | * the same as the value in the table 30 | */ 31 | class ForexEodSpec extends Specification with DataTables { 32 | 33 | val key = sys.env.getOrElse("OER_KEY", "") 34 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 35 | 36 | override def is = 37 | skipAllIf(sys.env.get("OER_KEY").isEmpty) ^ 38 | "end-of-date lookup tests: forex rate between two currencies for a specific date is always the same" ! e1 39 | 40 | // Table values obtained from OER API 41 | def e1 = 42 | "SOURCE CURRENCY" || "TARGET CURRENCY" | "DATE" | "EXPECTED OUTPUT" | 43 | CurrencyUnit.USD !! CurrencyUnit.GBP ! "2011-03-13T13:12:01+00:00" ! "0.62" | 44 | CurrencyUnit.USD !! CurrencyUnit.of("AED") ! "2011-03-13T01:13:04+00:00" ! "3.67" | 45 | CurrencyUnit.USD !! CurrencyUnit.CAD ! "2011-03-13T22:13:01+00:00" ! "0.98" | 46 | CurrencyUnit.GBP !! CurrencyUnit.USD ! "2011-03-13T11:45:34+00:00" ! "1.60" | 47 | CurrencyUnit.GBP !! CurrencyUnit.of("SGD") ! "2008-03-13T00:01:01+00:00" ! "2.80" |> { 48 | (fromCurr, toCurr, date, exp) => 49 | ioFx 50 | .flatMap( 51 | _.rate(fromCurr).to(toCurr).eod(ZonedDateTime.parse(date, DateTimeFormatter.ISO_OFFSET_DATE_TIME)) 52 | ) 53 | .unsafeRunSync() must beRight((m: Money) => m.getAmount.toString mustEqual exp) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/ForexNowSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.math.RoundingMode 16 | 17 | import cats.effect.IO 18 | import cats.effect.unsafe.implicits.global 19 | import org.joda.money._ 20 | import org.specs2.mutable.Specification 21 | 22 | import model._ 23 | 24 | /** Testing method for getting the live exchange rate */ 25 | class ForexNowSpec extends Specification { 26 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 27 | 28 | val key = sys.env.getOrElse("OER_KEY", "") 29 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 30 | val ioFxWithBaseGBP = 31 | CreateForex[IO].create(ForexConfig(key, EnterpriseAccount, baseCurrency = CurrencyUnit.GBP)) 32 | 33 | /** Trade 10000 USD to JPY at live exchange rate */ 34 | "convert 10000 USD dollars to Yen now" should { 35 | "be > 10000" in { 36 | val ioTradeInYenNow = ioFx.flatMap(_.convert(10000).to(CurrencyUnit.JPY).now) 37 | ioTradeInYenNow.unsafeRunSync() must beRight((m: Money) => 38 | m.isGreaterThan(Money.of(CurrencyUnit.JPY, 10000, RoundingMode.HALF_EVEN)) 39 | ) 40 | val ioTradeInYenNow2 = ioFx.flatMap(_.convert(10000, CurrencyUnit.USD).to(CurrencyUnit.JPY).now) 41 | ioTradeInYenNow2.unsafeRunSync() must beRight((m: Money) => 42 | m.isGreaterThan(Money.of(CurrencyUnit.JPY, 10000, RoundingMode.HALF_EVEN)) 43 | ) 44 | } 45 | } 46 | 47 | /** GBP -> SGD with USD as base currency */ 48 | "GBP to SGD with base currency USD live exchange rate" should { 49 | "be greater than 1 SGD" in { 50 | val ioGbpToSgdWithBaseUsd = 51 | ioFx.flatMap(_.rate(CurrencyUnit.GBP).to(CurrencyUnit.of("SGD")).now) 52 | ioGbpToSgdWithBaseUsd.unsafeRunSync() must beRight((m: Money) => 53 | m.isGreaterThan(Money.of(CurrencyUnit.of("SGD"), 1)) 54 | ) 55 | } 56 | } 57 | 58 | /** GBP -> SGD with GBP as base currency */ 59 | "GBP to SGD with base currency GBP live exchange rate" should { 60 | "be greater than 1 SGD" in { 61 | val ioGbpToSgdWithBaseGbp = ioFxWithBaseGBP.flatMap(_.rate.to(CurrencyUnit.of("SGD")).now) 62 | ioGbpToSgdWithBaseGbp.unsafeRunSync() must beRight((m: Money) => 63 | m.isGreaterThan(Money.of(CurrencyUnit.of("SGD"), 1)) 64 | ) 65 | } 66 | } 67 | 68 | /** GBP with GBP as base currency */ 69 | "Do not throw JodaTime exception on converting identical currencies" should { 70 | "be equal 1 GBP" in { 71 | val ioGbpToGbpWithBaseGbp = ioFxWithBaseGBP.flatMap(_.rate.to(CurrencyUnit.GBP).now) 72 | ioGbpToGbpWithBaseGbp.unsafeRunSync() must beRight((m: Money) => m.isEqual(Money.of(CurrencyUnit.of("GBP"), 1))) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/ForexNowishSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.math.RoundingMode 16 | 17 | import cats.effect.IO 18 | import cats.effect.unsafe.implicits.global 19 | import org.joda.money._ 20 | import org.specs2.mutable.Specification 21 | 22 | import model._ 23 | 24 | /** Testing method for getting the approximate exchange rate */ 25 | class ForexNowishSpec extends Specification { 26 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 27 | 28 | val key = sys.env.getOrElse("OER_KEY", "") 29 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 30 | val ioFxWithBaseGBP = 31 | CreateForex[IO].create(ForexConfig(key, EnterpriseAccount, baseCurrency = CurrencyUnit.GBP)) 32 | 33 | /** CAD -> GBP with base currency USD */ 34 | "CAD to GBP with USD as base currency returning near-live rate" should { 35 | "be smaller than 1 pound" in { 36 | val ioCadOverGbpNowish = ioFx.flatMap(_.rate(CurrencyUnit.CAD).to(CurrencyUnit.GBP).nowish) 37 | ioCadOverGbpNowish.unsafeRunSync() must beRight((m: Money) => m.isLessThan(Money.of(CurrencyUnit.GBP, 1))) 38 | } 39 | } 40 | 41 | /** GBP -> JPY with base currency USD */ 42 | "GBP to JPY with USD as base currency returning near-live rate" should { 43 | "be greater than 1 Yen" in { 44 | val ioGbpToJpyWithBaseUsd = ioFx.flatMap(_.rate(CurrencyUnit.GBP).to(CurrencyUnit.JPY).nowish) 45 | ioGbpToJpyWithBaseUsd.unsafeRunSync() must beRight((m: Money) => 46 | m.isGreaterThan(BigMoney.of(CurrencyUnit.JPY, 1).toMoney(RoundingMode.HALF_EVEN)) 47 | ) 48 | } 49 | } 50 | 51 | /** GBP -> JPY with base currency GBP */ 52 | "GBP to JPY with GBP as base currency returning near-live rate" should { 53 | "be greater than 1 Yen" in { 54 | val ioGbpToJpyWithBaseGbp = ioFxWithBaseGBP.flatMap(_.rate.to(CurrencyUnit.JPY).nowish) 55 | ioGbpToJpyWithBaseGbp.unsafeRunSync() must beRight((m: Money) => 56 | m.isGreaterThan(BigMoney.of(CurrencyUnit.of("JPY"), 1).toMoney(RoundingMode.HALF_EVEN)) 57 | ) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/ForexWithoutCachesSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import cats.effect.IO 16 | import cats.effect.unsafe.implicits.global 17 | 18 | import org.specs2.mutable.Specification 19 | 20 | import model._ 21 | 22 | /** Testing that setting cache size to zero will disable the use of cache */ 23 | class ForexWithoutCachesSpec extends Specification { 24 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 25 | 26 | val key = sys.env.getOrElse("OER_KEY", "") 27 | 28 | "Setting both cache sizes to zero" should { 29 | "disable the use of caches" in { 30 | val ioFxWithoutCache = 31 | CreateForex[IO].create(ForexConfig(key, DeveloperAccount, nowishCacheSize = 0, eodCacheSize = 0)) 32 | ioFxWithoutCache.unsafeRunSync().client.eodCache.isEmpty 33 | ioFxWithoutCache.unsafeRunSync().client.nowishCache.isEmpty 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/OerClientSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.math.BigDecimal 16 | import java.time.ZonedDateTime 17 | 18 | import cats.effect.IO 19 | import cats.effect.unsafe.implicits.global 20 | import org.joda.money.CurrencyUnit 21 | import org.specs2.mutable.Specification 22 | 23 | import model._ 24 | 25 | /** Testing methods for Open exchange rate client */ 26 | class OerClientSpec extends Specification { 27 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 28 | 29 | val key = sys.env.getOrElse("OER_KEY", "") 30 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 31 | 32 | "live currency value for USD" should { 33 | "always equal to 1" in { 34 | ioFx.map(_.client).flatMap(_.getLiveCurrencyValue(CurrencyUnit.USD)).unsafeRunSync() must beRight( 35 | new BigDecimal(1) 36 | ) 37 | } 38 | } 39 | 40 | "live currency value for GBP" should { 41 | "be less than 1" in { 42 | val ioGbpLiveRate = ioFx.flatMap(_.client.getLiveCurrencyValue(CurrencyUnit.GBP)) 43 | ioGbpLiveRate.unsafeRunSync() must beRight((d: BigDecimal) => d.doubleValue < 1) 44 | } 45 | } 46 | 47 | "historical currency value for USD on 01/01/2008" should { 48 | "always equal to 1 as well" in { 49 | val date = ZonedDateTime.parse("2008-01-01T01:01:01.123+09:00") 50 | ioFx.flatMap(_.client.getHistoricalCurrencyValue(CurrencyUnit.USD, date)).unsafeRunSync() must beRight( 51 | new BigDecimal(1) 52 | ) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/SpiedCacheSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.{ZoneId, ZonedDateTime} 16 | 17 | import scala.concurrent.duration._ 18 | 19 | import cats.effect.IO 20 | import cats.effect.unsafe.implicits.global 21 | import org.joda.money.CurrencyUnit 22 | import org.specs2.mock.Mockito 23 | import org.specs2.mutable.Specification 24 | 25 | import com.snowplowanalytics.lrumap.CreateLruMap 26 | import model._ 27 | 28 | /** Testing cache behaviours */ 29 | class SpiedCacheSpec extends Specification with Mockito { 30 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 31 | 32 | val key = sys.env.getOrElse("OER_KEY", "") 33 | val config = ForexConfig(key, DeveloperAccount) 34 | val fxConfigWith5NowishSecs = ForexConfig(key, DeveloperAccount, nowishSecs = 5) 35 | 36 | val spiedIoNowishCache = spy( 37 | CreateLruMap[IO, NowishCacheKey, NowishCacheValue].create(config.nowishCacheSize).unsafeRunSync() 38 | ) 39 | val spiedIoEodCache = spy( 40 | CreateLruMap[IO, EodCacheKey, EodCacheValue].create(config.eodCacheSize).unsafeRunSync() 41 | ) 42 | val ioClient = OerClient.getClient[IO]( 43 | config, 44 | Some(spiedIoNowishCache), 45 | Some(spiedIoEodCache) 46 | ) 47 | val spiedIoFx = Forex[IO](config, ioClient) 48 | val spiedIoFxWith5NowishSecs = Forex[IO](fxConfigWith5NowishSecs, ioClient) 49 | 50 | /** 51 | * nowish cache with 5-sec memory 52 | */ 53 | "A lookup of CAD->GBP within memory time limit" should { 54 | "return the value stored in the nowish cache and be overwritten after configured time by a new request" in { 55 | val ioAction = for { 56 | // call nowish, update the cache with key("CAD","GBP") and corresponding value 57 | _ <- spiedIoFxWith5NowishSecs.rate(CurrencyUnit.CAD).to(CurrencyUnit.GBP).nowish 58 | // get the value from the first HTPP request 59 | valueFromFirstHttpRequest <- spiedIoNowishCache.get((CurrencyUnit.CAD, CurrencyUnit.GBP)) 60 | // call nowish within 5 secs will get the value from the cache which is the same as valueFromFirstHttpRequest 61 | _ <- spiedIoFxWith5NowishSecs.rate(CurrencyUnit.CAD).to(CurrencyUnit.GBP).nowish 62 | 63 | valueFromCache <- spiedIoNowishCache.get((CurrencyUnit.CAD, CurrencyUnit.GBP)) 64 | test1 = (valueFromCache must be).equalTo(valueFromFirstHttpRequest) 65 | 66 | _ <- IO.sleep(6.seconds) 67 | // nowish will get the value over HTTP request, which will replace the previous value in the cache 68 | _ <- spiedIoFxWith5NowishSecs.rate(CurrencyUnit.CAD).to(CurrencyUnit.GBP).nowish 69 | 70 | // value will be different from previous value - 71 | // even if the monetary value is the same, the 72 | // timestamp will be different 73 | newValueFromCache <- spiedIoNowishCache.get((CurrencyUnit.CAD, CurrencyUnit.GBP)) 74 | test2 = newValueFromCache mustNotEqual valueFromFirstHttpRequest 75 | } yield test1.and(test2) 76 | 77 | ioAction.unsafeRunSync() 78 | } 79 | } 80 | 81 | /** 82 | * CAD -> GBP with base currency USD on 13-03-2011 83 | * The eod lookup will call get method on eod cache 84 | * after the call, the key will be stored in the cache 85 | */ 86 | val date = ZonedDateTime.of(2011, 3, 13, 0, 0, 0, 0, ZoneId.systemDefault) 87 | 88 | "Eod query on CAD->GBP" should { 89 | "call get method on eod cache and the cache should have (CAD, GBP) entry after" in { 90 | spiedIoFx 91 | .rate(CurrencyUnit.CAD) 92 | .to(CurrencyUnit.GBP) 93 | .eod(date) 94 | .productR(spiedIoEodCache.get((CurrencyUnit.CAD, CurrencyUnit.GBP, date))) 95 | .map { valueFromCache => 96 | there 97 | .was( 98 | two(spiedIoEodCache).get( 99 | (CurrencyUnit.CAD, CurrencyUnit.GBP, date) 100 | ) 101 | ) 102 | .and(valueFromCache must beSome) 103 | } 104 | .unsafeRunSync() 105 | 106 | } 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/test/scala/com.snowplowanalytics.forex/UnsupportedEodSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. 3 | * 4 | * This program is licensed to you under the Apache License Version 2.0, 5 | * and you may not use this file except in compliance with the Apache License Version 2.0. 6 | * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. 7 | * 8 | * Unless required by applicable law or agreed to in writing, 9 | * software distributed under the Apache License Version 2.0 is distributed on an 10 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. 12 | */ 13 | package com.snowplowanalytics.forex 14 | 15 | import java.time.{ZoneId, ZonedDateTime} 16 | 17 | import cats.effect.IO 18 | import cats.effect.unsafe.implicits.global 19 | import org.joda.money.CurrencyUnit 20 | import org.specs2.mutable.Specification 21 | 22 | import errors._ 23 | import model._ 24 | 25 | /** 26 | * Testing for exceptions caused by invalid dates 27 | */ 28 | class UnsupportedEodSpec extends Specification { 29 | args(skipAll = sys.env.get("OER_KEY").isEmpty) 30 | 31 | val key = sys.env.getOrElse("OER_KEY", "") 32 | val ioFx = CreateForex[IO].create(ForexConfig(key, DeveloperAccount)) 33 | 34 | "An end-of-date lookup in 1900" should { 35 | "throw an exception" in { 36 | 37 | /** 38 | * 1900 is earlier than 1990 which is the earliest available date for looking up exchange 39 | * rates 40 | */ 41 | val date1900 = ZonedDateTime.of(1900, 3, 13, 0, 0, 0, 0, ZoneId.systemDefault) 42 | ioFx.flatMap(_.rate.to(CurrencyUnit.GBP).eod(date1900)).unsafeRunSync() must beLike { 43 | case Left(OerResponseError(_, ResourcesNotAvailable)) => ok 44 | } 45 | } 46 | } 47 | 48 | "An end-of-date lookup in 2030" should { 49 | "throw an exception" in { 50 | 51 | /** 2030 is in the future so it won't be available either */ 52 | val date2030 = ZonedDateTime.of(2030, 3, 13, 0, 0, 0, 0, ZoneId.systemDefault) 53 | ioFx.flatMap(_.rate.to(CurrencyUnit.GBP).eod(date2030)).unsafeRunSync() must beLike { 54 | case Left(OerResponseError(_, ResourcesNotAvailable)) => ok 55 | } 56 | } 57 | } 58 | } 59 | --------------------------------------------------------------------------------