├── .github ├── CODEOWNERS ├── release-drafter.yml └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .sbtopts ├── .scalafmt.conf ├── CHANGELOG.md ├── HOW-TO-RELEASE.md ├── LICENSE ├── README.md ├── benchmarks ├── README.md └── src │ └── main │ └── scala │ └── json │ ├── DateTimeFromJSONBenchmark.scala │ ├── EnumFromJSONBenchmark.scala │ ├── FromJsonBenchmark.scala │ ├── FromMongoBenchmark.scala │ ├── JsonBenchmark.scala │ ├── ParseJsonBenchmark.scala │ ├── ToJsonBenchmark.scala │ └── ToMongoValueBenchmark.scala ├── build.sbt ├── json ├── LICENSE.md ├── README.md ├── json-core │ ├── dependencies.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── io │ │ │ └── sphere │ │ │ └── json │ │ │ ├── FromJSON.scala │ │ │ ├── JSON.scala │ │ │ ├── SphereJsonParser.scala │ │ │ ├── ToJSON.scala │ │ │ ├── catsinstances │ │ │ └── package.scala │ │ │ └── package.scala │ │ └── test │ │ └── scala │ │ └── io │ │ └── sphere │ │ └── json │ │ ├── BigNumberParsingSpec.scala │ │ ├── DateTimeParsingSpec.scala │ │ ├── JSONProperties.scala │ │ ├── JSONSpec.scala │ │ ├── JodaJavaLocalDateCompatSpec.scala │ │ ├── JodaJavaTimeCompat.scala │ │ ├── MoneyMarshallingSpec.scala │ │ ├── SetHandlingSpec.scala │ │ ├── SphereJsonExample.scala │ │ ├── SphereJsonParserSpec.scala │ │ ├── ToJSONSpec.scala │ │ └── catsinstances │ │ └── JSONCatsInstancesTest.scala └── json-derivation │ └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── sphere │ │ │ └── json │ │ │ └── annotations │ │ │ ├── JSONEmbedded.java │ │ │ ├── JSONIgnore.java │ │ │ ├── JSONKey.java │ │ │ ├── JSONTypeHint.java │ │ │ └── JSONTypeHintField.java │ └── scala │ │ └── io │ │ └── sphere │ │ └── json │ │ ├── ToJSONProduct.fmpp.scala │ │ └── generic │ │ ├── JSONMacros.scala │ │ └── package.fmpp.scala │ └── test │ └── scala │ └── io │ └── sphere │ └── json │ ├── DeriveSingletonJSONSpec.scala │ ├── ForProductNSpec.scala │ ├── JSONEmbeddedSpec.scala │ ├── JSONSpec.scala │ ├── NullHandlingSpec.scala │ ├── OptionReaderSpec.scala │ ├── TypesSwitchSpec.scala │ └── generic │ ├── DefaultValuesSpec.scala │ ├── JSONKeySpec.scala │ └── JsonTypeHintFieldSpec.scala ├── mongo ├── README.md ├── mongo-core │ ├── dependencies.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── io │ │ │ └── sphere │ │ │ └── mongo │ │ │ ├── catsinstances │ │ │ └── package.scala │ │ │ └── format │ │ │ ├── DefaultMongoFormats.scala │ │ │ ├── MongoFormat.scala │ │ │ └── package.scala │ │ └── test │ │ └── scala │ │ └── io │ │ └── sphere │ │ └── mongo │ │ ├── MongoUtils.scala │ │ ├── catsinstances │ │ └── MongoFormatCatsInstancesTest.scala │ │ └── format │ │ ├── BaseMoneyMongoFormatTest.scala │ │ └── DefaultMongoFormatsTest.scala ├── mongo-derivation-magnolia │ ├── dependencies.sbt │ └── src │ │ ├── main │ │ └── scala │ │ │ └── io │ │ │ └── sphere │ │ │ └── mongo │ │ │ └── generic │ │ │ ├── MongoEmbedded.scala │ │ │ ├── MongoIgnore.scala │ │ │ ├── MongoKey.scala │ │ │ ├── MongoTypeHint.scala │ │ │ ├── MongoTypeHintField.scala │ │ │ └── package.scala │ │ └── test │ │ └── scala │ │ └── io │ │ └── sphere │ │ └── mongo │ │ ├── MongoUtils.scala │ │ ├── SerializationTest.scala │ │ ├── format │ │ └── OptionMongoFormatSpec.scala │ │ └── generic │ │ ├── DefaultValuesSpec.scala │ │ ├── DeriveMongoformatSpec.scala │ │ ├── MongoEmbeddedSpec.scala │ │ ├── MongoKeySpec.scala │ │ ├── MongoTypeHintFieldWithAbstractClassSpec.scala │ │ ├── MongoTypeHintFieldWithSealedTraitSpec.scala │ │ └── SumTypesDerivingSpec.scala └── mongo-derivation │ └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── sphere │ │ │ └── mongo │ │ │ └── generic │ │ │ └── annotations │ │ │ ├── MongoEmbedded.java │ │ │ ├── MongoIgnore.java │ │ │ ├── MongoKey.java │ │ │ ├── MongoProvidedFormatter.java │ │ │ ├── MongoTypeHint.java │ │ │ └── MongoTypeHintField.java │ └── scala │ │ └── io │ │ └── sphere │ │ └── mongo │ │ └── generic │ │ ├── MongoFormatMacros.scala │ │ └── package.fmpp.scala │ └── test │ └── scala │ └── io │ └── sphere │ └── mongo │ ├── MongoUtils.scala │ ├── SerializationTest.scala │ ├── format │ └── OptionMongoFormatSpec.scala │ └── generic │ ├── DefaultValuesSpec.scala │ ├── DeriveMongoformatSpec.scala │ ├── MongoEmbeddedSpec.scala │ ├── MongoKeySpec.scala │ ├── MongoTypeHintFieldWithAbstractClassSpec.scala │ ├── MongoTypeHintFieldWithSealedTraitSpec.scala │ └── SumTypesDerivingSpec.scala ├── project ├── Fmpp.scala ├── build.properties └── plugins.sbt └── util ├── dependencies.sbt └── src ├── main └── scala │ ├── Concurrent.scala │ ├── DateTimeFormats.scala │ ├── LangTag.scala │ ├── Logging.scala │ ├── Memoizer.scala │ ├── Money.scala │ ├── Reflect.scala │ └── ValidatedFlatMap.scala └── test └── scala ├── DateTimeFormatsRoundtripSpec.scala ├── DateTimeFormatsSpec.scala ├── DomainObjectsGen.scala ├── HighPrecisionMoneySpec.scala ├── LangTagSpec.scala ├── MoneySpec.scala └── ScalaLoggingCompatiblitySpec.scala /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # why this file: to pick reviewers for a pull request automatically 2 | # doc on https://help.github.com/articles/about-codeowners/ 3 | * @yanns 4 | 5 | # Priceless backend team domain 6 | *Money*.scala @commercetools/priceless-team-be 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: [ubuntu-latest] 27 | scala: [2.12.20, 2.13.16, 3.3.5] 28 | java: [temurin@21] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout current branch (full) 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Setup Java (temurin@21) 37 | if: matrix.java == 'temurin@21' 38 | uses: actions/setup-java@v4 39 | with: 40 | distribution: temurin 41 | java-version: 21 42 | cache: sbt 43 | 44 | - name: Setup sbt 45 | uses: sbt/setup-sbt@v1 46 | 47 | - name: Check formatting 48 | run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll 49 | 50 | - name: Check that workflows are up to date 51 | run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck 52 | 53 | - name: Build Scala 2 project 54 | if: matrix.scala != '3.3.5' 55 | run: sbt '++ ${{ matrix.scala }}' test 56 | 57 | - name: Build Scala 3 project 58 | if: matrix.scala == '3.3.5' 59 | run: sbt '++ ${{ matrix.scala }}' sphere-util/test sphere-json-core/test sphere-mongo-core/test 60 | 61 | - name: Compress target directories 62 | run: tar cf targets.tar benchmarks/target mongo/target json/json-core/target mongo/mongo-core/target json/target util/target json/json-derivation/target mongo/mongo-derivation-magnolia/target target mongo/mongo-derivation/target project/target 63 | 64 | - name: Upload target directories 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 68 | path: targets.tar 69 | 70 | publish: 71 | name: Publish Artifacts 72 | needs: [build] 73 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 74 | strategy: 75 | matrix: 76 | os: [ubuntu-latest] 77 | scala: [2.13.16] 78 | java: [temurin@21] 79 | runs-on: ${{ matrix.os }} 80 | steps: 81 | - name: Checkout current branch (full) 82 | uses: actions/checkout@v4 83 | with: 84 | fetch-depth: 0 85 | 86 | - name: Setup Java (temurin@21) 87 | if: matrix.java == 'temurin@21' 88 | uses: actions/setup-java@v4 89 | with: 90 | distribution: temurin 91 | java-version: 21 92 | cache: sbt 93 | 94 | - name: Setup sbt 95 | uses: sbt/setup-sbt@v1 96 | 97 | - name: Download target directories (2.12.20) 98 | uses: actions/download-artifact@v4 99 | with: 100 | name: target-${{ matrix.os }}-2.12.20-${{ matrix.java }} 101 | 102 | - name: Inflate target directories (2.12.20) 103 | run: | 104 | tar xf targets.tar 105 | rm targets.tar 106 | 107 | - name: Download target directories (2.13.16) 108 | uses: actions/download-artifact@v4 109 | with: 110 | name: target-${{ matrix.os }}-2.13.16-${{ matrix.java }} 111 | 112 | - name: Inflate target directories (2.13.16) 113 | run: | 114 | tar xf targets.tar 115 | rm targets.tar 116 | 117 | - name: Download target directories (3.3.5) 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: target-${{ matrix.os }}-3.3.5-${{ matrix.java }} 121 | 122 | - name: Inflate target directories (3.3.5) 123 | run: | 124 | tar xf targets.tar 125 | rm targets.tar 126 | 127 | - env: 128 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 129 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 130 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 131 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 132 | run: sbt ci-release 133 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | shell: bash {0} 21 | run: | 22 | # Customize those three lines with your repository and credentials: 23 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 24 | 25 | # A shortcut to call GitHub API. 26 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 27 | 28 | # A temporary file which receives HTTP response headers. 29 | TMPFILE=$(mktemp) 30 | 31 | # An associative array, key: artifact name, value: number of artifacts of that name. 32 | declare -A ARTCOUNT 33 | 34 | # Process all artifacts on this repository, loop on returned "pages". 35 | URL=$REPO/actions/artifacts 36 | while [[ -n "$URL" ]]; do 37 | 38 | # Get current page, get response headers in a temporary file. 39 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 40 | 41 | # Get URL of next page. Will be empty if we are at the last page. 42 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 43 | rm -f $TMPFILE 44 | 45 | # Number of artifacts on this page: 46 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 47 | 48 | # Loop on all artifacts on this page. 49 | for ((i=0; $i < $COUNT; i++)); do 50 | 51 | # Get name of artifact and count instances of this name. 52 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 53 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 54 | 55 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 56 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 57 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 58 | ghapi -X DELETE $REPO/actions/artifacts/$id 59 | done 60 | done 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *~ 4 | .DS_Store 5 | target 6 | boot 7 | tags 8 | lib_managed 9 | *.iml 10 | .idea 11 | /.sbtconfig 12 | /tmtags 13 | *.log 14 | .ensime 15 | .ensime_lucene 16 | .tags 17 | *.sublime-project 18 | *.sublime-workspace 19 | plugins-ide.sbt 20 | /data/ 21 | src_managed 22 | *.deb 23 | *.changes 24 | .bloop 25 | .bsp 26 | .metals 27 | metals.sbt 28 | .vscode 29 | *.worksheet.sc 30 | -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-XX:+UnlockDiagnosticVMOptions 2 | -J-XX:MaxMetaspaceSize=2g 3 | -J-Xmx2g 4 | -J-Xss2M 5 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.8.3 2 | 3 | runner.dialect = scala213 4 | 5 | maxColumn = 100 6 | 7 | // Vertical alignment is pretty, but leads to bigger diffs 8 | align.preset = none 9 | 10 | danglingParentheses.preset = false 11 | 12 | project.excludeFilters = [ 13 | ".fmpp.scala" 14 | ] 15 | 16 | rewrite.rules = [ 17 | AvoidInfix 18 | RedundantBraces 19 | RedundantParens 20 | AsciiSortImports 21 | PreferCurlyFors 22 | ] 23 | -------------------------------------------------------------------------------- /HOW-TO-RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | How to create a new [release](../../releases). 4 | 5 | ## Releasing 6 | 7 | The release process is automated thanks to: 8 | - https://github.com/djspiewak/sbt-github-actions#integration-with-sbt-ci-release 9 | - https://github.com/olafurpg/sbt-ci-release 10 | 11 | To release, push a git tag: 12 | 13 | ``` 14 | git tag -a v0.1.0 -m "v0.1.0" 15 | git push origin v0.1.0 16 | ``` 17 | Note that the tag version MUST start with `v`. 18 | 19 | Wait for the [CI pipeline](../../actions) to release the new version. Publishing the artifacts on maven central can take time. 20 | 21 | ## Updating the release notes 22 | 23 | Open the [releases](../../releases). A draft should already be prepared. 24 | 25 | Edit the draft release to set the released version. Complete the release notes if necessary. And save it. 26 | 27 | ## Troubleshooting 28 | 29 | If the `Publish Artifacts` job fails, it might be caused by an expired GPG signing key. 30 | In that case you'll find the following entries in the job log: 31 | ```text 32 | [info] gpg: no default secret key: No secret key 33 | [info] gpg: signing failed: No secret key 34 | ``` 35 | 36 | To resolve the issue, you have to rotate the GPG signing key. 37 | Follow the instructions, found [here](https://github.com/sbt/sbt-ci-release#gpg). 38 | 39 | * for real name, use `sbt-ci-release bot` 40 | * for email address, use `info@commercetools.com` 41 | 42 | If the programmatic public key publishing fails for you (`gpg: keyserver send failed: Server indicated a failure`), you can publish the public key manually. 43 | Use the forms on https://keyserver.ubuntu.com/ and http://pgp.mit.edu:11371/. 44 | It's important to add the public key to both key servers! 45 | 46 | Finally, update the `PGP_PASSPHRASE` and `PGP_SECRET` secrets in the [repository settings](https://github.com/commercetools/sphere-scala-libs/settings/secrets/actions). 47 | 48 | _There is no need to save the key details anywhere else, because it's only used for signing and will never be verified. 49 | If the credentials are lost, we can just generate a new key._ 50 | 51 | _Since the default key expiration timeout is 2 full years, it is not necessary to set up any rotation reminders. 52 | The publishing failure and this troubleshooting guide should be enough to quickly resolve the problem._ 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sphere-scala-libs 2 | ================= 3 | 4 | Just some Scala libraries that started out as internal projects as part of the [commercetools platform](http://dev.commercetools.com/) (that was originally named sphere.io) and have been made public in the hope that they might be useful to more people. 5 | 6 | ## Download 7 | 8 | sphere-json: [![latest release](https://img.shields.io/maven-central/v/com.commercetools/sphere-json_2.13.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.commercetools%20AND%20a:sphere-json*) 9 | 10 | 11 | sphere-util: [![latest release](https://img.shields.io/maven-central/v/com.commercetools/sphere-util_2.13.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.commercetools%20AND%20a:sphere-util*) 12 | 13 | 14 | sphere-mongo: [![latest release](https://img.shields.io/maven-central/v/com.commercetools/sphere-mongo_2.13.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.commercetools%20AND%20a:sphere-mongo*) 15 | 16 | 17 | ## Documentation 18 | 19 | [sphere-json](json/README.md) 20 | 21 | # Release a new version 22 | 23 | See [Release process](HOW-TO-RELEASE.md) 24 | 25 | ## License 26 | 27 | Licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 28 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # benchmarks for sphere libs 2 | 3 | The benchmarks are run with [OpenJDK JMH](http://openjdk.java.net/projects/code-tools/jmh/) and integrated in sbt with [sbt-jmh](https://github.com/ktoso/sbt-jmh) 4 | 5 | When developing a new benchmark: 6 | 7 | ``` 8 | jmh:run -i 1 -wi 1 -f1 -t1 9 | ``` 10 | 11 | where: 12 | 13 | -i Number of measurement iterations to do. 14 | -wi Number of warmup iterations to do. 15 | -f How many times to forks a single benchmark. 16 | -t Number of worker threads to run with. 17 | 18 | Other options can be found with 19 | ``` 20 | jmh:run -h 21 | ``` 22 | 23 | For a real benchmark: 24 | ``` 25 | jmh:run 26 | ``` 27 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/DateTimeFromJSONBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import io.sphere.json.{FromJSON, JValidation} 4 | import org.joda.time.DateTime 5 | import org.json4s.JsonAST.JString 6 | import org.openjdk.jmh.annotations._ 7 | 8 | import scala.annotation.nowarn 9 | 10 | @State(Scope.Benchmark) 11 | @BenchmarkMode(Array(Mode.Throughput)) 12 | @Warmup(iterations = 10, time = 1) 13 | @Measurement(iterations = 10, time = 1) 14 | @Fork(value = 1) 15 | @nowarn("msg=unused value of type") 16 | class DateTimeFromJSONBenchmark { 17 | 18 | @Param( 19 | Array( 20 | "2025-12-14T12:50:25.070Z", 21 | "2022-09-05T00:18:33.994Z" 22 | )) 23 | var rawValue: String = _ 24 | private var json: JString = _ 25 | 26 | @Setup 27 | final def setupJson(): Unit = 28 | json = JString(rawValue) 29 | 30 | import FromJSON.dateTimeReader 31 | @Benchmark 32 | final def deserializeDateTime(): JValidation[DateTime] = 33 | dateTimeReader.read(json) 34 | } 35 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/EnumFromJSONBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import io.sphere.json.generic.fromJsonEnum 4 | import io.sphere.json.{FromJSON, JValidation} 5 | import org.json4s.JsonAST.JString 6 | import org.openjdk.jmh.annotations._ 7 | 8 | import scala.annotation.nowarn 9 | 10 | object FewCasesEnum extends Enumeration { 11 | val First, Middle, End = Value 12 | } 13 | 14 | object ManyCasesEnum extends Enumeration { 15 | val First, A, B, C, D, E, F, G, H, I, J, K, L, Middle, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, 16 | End = Value 17 | } 18 | 19 | @State(Scope.Benchmark) 20 | @BenchmarkMode(Array(Mode.Throughput)) 21 | @Warmup(iterations = 10, time = 1) 22 | @Measurement(iterations = 10, time = 1) 23 | @Fork(value = 1) 24 | @nowarn("msg=unused value of type") 25 | class EnumFromJSONBenchmark { 26 | private val fromJSONForManyCases: FromJSON[ManyCasesEnum.Value] = fromJsonEnum(ManyCasesEnum) 27 | private val fromJSONForFewCases: FromJSON[FewCasesEnum.Value] = fromJsonEnum(FewCasesEnum) 28 | 29 | @Param( 30 | Array( 31 | "First", 32 | "Middle", 33 | "End" 34 | )) 35 | var rawValue: String = _ 36 | private var json: JString = _ 37 | 38 | @Setup 39 | final def setupJson(): Unit = 40 | json = JString(rawValue) 41 | 42 | @Benchmark 43 | final def enumWithManyCasesFromJson(): JValidation[ManyCasesEnum.Value] = 44 | fromJSONForManyCases.read(json) 45 | 46 | @Benchmark 47 | final def enumWithFewCasesFromJson(): JValidation[FewCasesEnum.Value] = 48 | fromJSONForFewCases.read(json) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/FromJsonBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import io.sphere.json._ 4 | import org.openjdk.jmh.annotations._ 5 | 6 | @State(Scope.Benchmark) 7 | @BenchmarkMode(Array(Mode.Throughput)) 8 | @Warmup(iterations = 10, time = 1) 9 | @Measurement(iterations = 10, time = 1) 10 | @Fork(value = 1) 11 | class FromJsonBenchmark { 12 | 13 | /* on local mac 14 | jmh:run 15 | 16 | *** scala 2.12 *** 17 | Benchmark Mode Cnt Score Error Units 18 | FromJsonBenchmark.listReader thrpt 10 69,925 ± 0,776 ops/s 19 | FromJsonBenchmark.parseFromStringToCaseClass thrpt 10 19,010 ± 0,186 ops/s 20 | FromJsonBenchmark.seqReader thrpt 10 72,595 ± 0,737 ops/s 21 | FromJsonBenchmark.vectorReader thrpt 10 71,278 ± 0,896 ops/s 22 | 23 | *** scala 2.13 *** 24 | Benchmark Mode Cnt Score Error Units 25 | FromJsonBenchmark.listReader thrpt 10 70,661 ± 1,623 ops/s 26 | FromJsonBenchmark.parseFromStringToCaseClass thrpt 10 19,288 ± 0,118 ops/s 27 | FromJsonBenchmark.seqReader thrpt 10 72,897 ± 1,317 ops/s 28 | FromJsonBenchmark.vectorReader thrpt 10 72,016 ± 0,552 ops/s 29 | */ 30 | 31 | @Benchmark 32 | def parseFromStringToCaseClass(): Unit = { 33 | val product = getFromJSON[Product](JsonBenchmark.json) 34 | assert(product.version == 2) 35 | } 36 | 37 | @Benchmark 38 | def vectorReader(): Unit = 39 | fromJSON[Vector[Int]](JsonBenchmark.lotsOfIntsAsJson) 40 | 41 | @Benchmark 42 | def listReader(): Unit = 43 | fromJSON[List[Int]](JsonBenchmark.lotsOfIntsAsJson) 44 | 45 | @Benchmark 46 | def seqReader(): Unit = 47 | fromJSON[Seq[Int]](JsonBenchmark.lotsOfIntsAsJson) 48 | 49 | } 50 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/FromMongoBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import io.sphere.mongo.format.fromMongo 4 | import io.sphere.mongo.format.DefaultMongoFormats._ 5 | import org.openjdk.jmh.annotations.{ 6 | Benchmark, 7 | BenchmarkMode, 8 | Fork, 9 | Measurement, 10 | Mode, 11 | Scope, 12 | State, 13 | Warmup 14 | } 15 | 16 | @State(Scope.Benchmark) 17 | @BenchmarkMode(Array(Mode.Throughput)) 18 | @Warmup(iterations = 10, time = 1) 19 | @Measurement(iterations = 10, time = 1) 20 | @Fork(value = 1) 21 | class FromMongoBenchmark { 22 | 23 | /* on local mac 24 | jmh:run 25 | 26 | *** scala 2.13 *** 27 | Benchmark Mode Cnt Score Error Units 28 | FromMongoBenchmark.mongoValueToCaseClass thrpt 10 25,306 ± 0,832 ops/s 29 | FromMongoBenchmark.mongoValueToList thrpt 10 521,449 ± 17,672 ops/s 30 | FromMongoBenchmark.mongoValueToMap thrpt 10 51,554 ± 0,648 ops/s 31 | FromMongoBenchmark.mongoValueToVector thrpt 10 1334,286 ± 21,065 ops/s 32 | */ 33 | 34 | @Benchmark 35 | def mongoValueToCaseClass(): Unit = { 36 | val product = fromMongo[Product](JsonBenchmark.productMongoValue) 37 | assert(product.version == 2) 38 | } 39 | 40 | @Benchmark 41 | def mongoValueToVector(): Unit = { 42 | val vector = fromMongo[Vector[Int]](JsonBenchmark.lotsOfIntsMongoValue) 43 | assert(JsonBenchmark.lotsOfIntsVector.size == vector.size) 44 | } 45 | 46 | @Benchmark 47 | def mongoValueToList(): Unit = { 48 | val list = fromMongo[List[Int]](JsonBenchmark.lotsOfIntsMongoValue) 49 | assert(JsonBenchmark.lotsOfIntsVector.size == list.size) 50 | } 51 | 52 | @Benchmark 53 | def mongoValueToMap(): Unit = { 54 | val map = fromMongo[Map[String, String]](JsonBenchmark.bigMapMongoValue) 55 | assert(JsonBenchmark.bigMap.size == map.size) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/JsonBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import java.util.UUID 4 | 5 | import io.sphere.json._ 6 | import io.sphere.json.generic._ 7 | import io.sphere.mongo.generic._ 8 | import io.sphere.mongo.format.MongoFormat 9 | import io.sphere.mongo.format.DefaultMongoFormats._ 10 | import io.sphere.mongo.format._ 11 | import io.sphere.util.BaseMoney 12 | import org.joda.time.format.ISODateTimeFormat 13 | import org.joda.time.{DateTime, DateTimeZone} 14 | 15 | import scala.collection.generic.CanBuildFrom 16 | import scala.language.higherKinds 17 | 18 | case class Reference(typeId: String, id: UUID) 19 | 20 | object Reference { 21 | implicit val json: JSON[Reference] = jsonProduct(apply _) 22 | implicit val mongoFormat: MongoFormat[Reference] = mongoProduct(apply _) 23 | } 24 | 25 | case class Price(id: String, value: BaseMoney, validUntil: DateTime) 26 | 27 | object Price { 28 | // the lib does not ship a `MongoFormat[DateTime]` 29 | implicit val dateTimeAsIsoStringFormat: MongoFormat[DateTime] = new MongoFormat[DateTime] { 30 | override def toMongoValue(dt: DateTime): Any = 31 | ISODateTimeFormat.dateTime.print(dt.withZone(DateTimeZone.UTC)) 32 | override def fromMongoValue(any: Any): DateTime = any match { 33 | case s: String => new DateTime(s, DateTimeZone.UTC) 34 | case _ => sys.error("String expected") 35 | } 36 | } 37 | 38 | implicit val json: JSON[Price] = jsonProduct(apply _) 39 | implicit val mongoFormat: MongoFormat[Price] = mongoProduct(apply _) 40 | } 41 | 42 | case class ProductVariant(id: Long, prices: Vector[Price], attributes: Map[String, String]) 43 | 44 | object ProductVariant { 45 | implicit val json: JSON[ProductVariant] = jsonProduct(apply _) 46 | implicit val mongoFormat: MongoFormat[ProductVariant] = mongoProduct(apply _) 47 | } 48 | 49 | case class Product( 50 | id: UUID, 51 | version: Long, 52 | productType: Reference, 53 | variants: Vector[ProductVariant]) 54 | 55 | object Product { 56 | implicit val json: JSON[Product] = jsonProduct(apply _) 57 | implicit val mongoFormat: MongoFormat[Product] = mongoProduct(apply _) 58 | } 59 | 60 | object JsonBenchmark { 61 | 62 | val lotsOfIntsList = Range(1, 100000).toList 63 | val lotsOfIntsSeq = Range(1, 100000).toSeq 64 | val lotsOfIntsVector = Range(1, 100000).toVector 65 | val lotsOfIntsAsJson = Range(1, 100000).mkString("[", ",", "]") 66 | val lotsOfIntsMongoValue = toMongo(lotsOfIntsVector) 67 | val bigMap: Map[String, String] = lotsOfIntsList.map(i => s"key$i" -> s"value$i").toMap 68 | val bigMapMongoValue = toMongo(bigMap) 69 | 70 | val prices = 71 | for (i <- 1 to 200) 72 | yield s""" 73 | |{ 74 | | "id": "$i", 75 | | "value": { 76 | | "centAmount": $i, 77 | | "currencyCode": "USD" 78 | | }, 79 | | "validUntil": "2025-12-14T12:50:25.070Z" 80 | |} 81 | """.stripMargin 82 | 83 | val customAttributes = 84 | (for (i <- 1 to 80) yield s""" "field-$i": "value $i" """).mkString("{", ",", "}") 85 | 86 | val variants = 87 | for (i <- 1 to 100) 88 | yield s"""{ 89 | | "id": $i, 90 | | "prices": ${prices.mkString("[", ",", "]")}, 91 | | "images": [], 92 | | "attributes": $customAttributes, 93 | | "categories":[] 94 | |}""".stripMargin 95 | 96 | val json = 97 | s"""{ 98 | | "id": "ff30b141-67e4-41bb-97c5-4121c42d602a", 99 | | "version": 2, 100 | | "productType": { 101 | | "typeId": "product-type", 102 | | "id": "5a4c142a-40b8-4b86-b944-2259d39ced22" 103 | | }, 104 | | "name": {"de-DE":"Ein Product 1","en":"Some Product 1"}, 105 | | "categories":[], 106 | | "categoryOrderHints":{}, 107 | | "slug": {"en":"product_slug_1_4ff4aaa3-2dc9-4aca-8db9-1c68a341de13"}, 108 | | "variants": ${variants.mkString("[", ",", "]")}, 109 | | "searchKeywords":{}, 110 | | "hasStagedChanges":false, 111 | | "published":true, 112 | | "createdAt":"2015-12-14T12:50:23.679Z", 113 | | "lastModifiedAt":"2015-12-14T12:50:25.070Z" 114 | |} 115 | """.stripMargin 116 | 117 | val product = getFromJSON[Product](json) 118 | val productMongoValue = toMongo(product) 119 | } 120 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/ParseJsonBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import org.json4s.StringInput 4 | import org.json4s.jackson._ 5 | import org.openjdk.jmh.annotations._ 6 | 7 | @State(Scope.Benchmark) 8 | @BenchmarkMode(Array(Mode.Throughput)) 9 | @Warmup(iterations = 10, time = 1) 10 | @Measurement(iterations = 10, time = 1) 11 | @Fork(value = 1) 12 | class ParseJsonBenchmark { 13 | 14 | /* on local mac 15 | jmh:run 16 | 17 | *** scala 2.12 *** 18 | Benchmark Mode Cnt Score Error Units 19 | ParseJsonBenchmark.parseFromStringToJValue thrpt 10 71,394 ± 0,459 ops/s 20 | 21 | *** scala 2.13 *** 22 | Benchmark Mode Cnt Score Error Units 23 | ParseJsonBenchmark.parseFromStringToJValue thrpt 10 72,156 ± 0,403 ops/s 24 | */ 25 | 26 | @Benchmark 27 | def parseFromStringToJValue(): Unit = { 28 | val jvalue = parseJson(JsonBenchmark.json) 29 | assert(jvalue != null) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/ToJsonBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import java.util.UUID 4 | 5 | import io.sphere.json._ 6 | import io.sphere.json.generic._ 7 | import io.sphere.util.BaseMoney 8 | import org.json4s.StringInput 9 | import org.json4s.jackson._ 10 | import org.openjdk.jmh.annotations._ 11 | 12 | @State(Scope.Benchmark) 13 | @BenchmarkMode(Array(Mode.Throughput)) 14 | @Warmup(iterations = 10, time = 1) 15 | @Measurement(iterations = 10, time = 1) 16 | @Fork(value = 1) 17 | class ToJsonBenchmark { 18 | 19 | /* on local mac 20 | jmh:run 21 | 22 | *** scala 2.12 *** 23 | Benchmark Mode Cnt Score Error Units 24 | ToJsonBenchmark.listWriter thrpt 10 70,604 ± 1,277 ops/s 25 | ToJsonBenchmark.seqWriter thrpt 10 28,650 ± 0,311 ops/s 26 | ToJsonBenchmark.serializeCaseClassToString thrpt 10 51,404 ± 0,748 ops/s 27 | ToJsonBenchmark.vectorWriter thrpt 10 61,722 ± 1,770 ops/s 28 | 29 | *** scala 2.13 *** 30 | Benchmark Mode Cnt Score Error Units 31 | ToJsonBenchmark.listWriter thrpt 10 73,688 ± 1,381 ops/s 32 | ToJsonBenchmark.seqWriter thrpt 10 70,049 ± 1,697 ops/s 33 | ToJsonBenchmark.serializeCaseClassToString thrpt 10 29,107 ± 0,417 ops/s 34 | ToJsonBenchmark.vectorWriter thrpt 10 70,300 ± 1,833 ops/s 35 | */ 36 | 37 | @Benchmark 38 | def serializeCaseClassToString(): Unit = { 39 | val json = toJSON[Product](JsonBenchmark.product) 40 | assert(json != null) 41 | } 42 | 43 | @Benchmark 44 | def vectorWriter(): Unit = 45 | toJSON[Vector[Int]](JsonBenchmark.lotsOfIntsVector) 46 | 47 | @Benchmark 48 | def listWriter(): Unit = 49 | toJSON[List[Int]](JsonBenchmark.lotsOfIntsList) 50 | 51 | @Benchmark 52 | def seqWriter(): Unit = 53 | toJSON[Seq[Int]](JsonBenchmark.lotsOfIntsSeq) 54 | 55 | } 56 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/json/ToMongoValueBenchmark.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import io.sphere.mongo.format._ 4 | import io.sphere.mongo.format.DefaultMongoFormats._ 5 | import org.openjdk.jmh.annotations.{ 6 | Benchmark, 7 | BenchmarkMode, 8 | Fork, 9 | Measurement, 10 | Mode, 11 | Scope, 12 | State, 13 | Warmup 14 | } 15 | 16 | @State(Scope.Benchmark) 17 | @BenchmarkMode(Array(Mode.Throughput)) 18 | @Warmup(iterations = 10, time = 1) 19 | @Measurement(iterations = 10, time = 1) 20 | @Fork(value = 1) 21 | class ToMongoValueBenchmark { 22 | 23 | /* on local mac 24 | jmh:run 25 | 26 | [info] Benchmark Mode Cnt Score Error Units 27 | [info] ToMongoValueBenchmark.caseClassToMongoValue thrpt 10 76,492 ± 0,968 ops/s 28 | [info] ToMongoValueBenchmark.listToMongoValue thrpt 10 484,802 ± 16,722 ops/s 29 | [info] ToMongoValueBenchmark.mapToMongoValueTo thrpt 10 30,316 ± 3,938 ops/s 30 | [info] ToMongoValueBenchmark.vectorToMongoValue thrpt 10 671,930 ± 17,021 ops/s 31 | */ 32 | 33 | @Benchmark 34 | def caseClassToMongoValue(): Unit = { 35 | val mongoValue = toMongo[Product](JsonBenchmark.product) 36 | assert(mongoValue != null) 37 | } 38 | 39 | @Benchmark 40 | def vectorToMongoValue(): Unit = { 41 | val mongoValue = toMongo[Vector[Int]](JsonBenchmark.lotsOfIntsVector) 42 | assert(mongoValue != null) 43 | } 44 | 45 | @Benchmark 46 | def listToMongoValue(): Unit = { 47 | val mongoValue = toMongo[List[Int]](JsonBenchmark.lotsOfIntsList) 48 | assert(mongoValue != null) 49 | } 50 | 51 | @Benchmark 52 | def mapToMongoValueTo(): Unit = { 53 | val mongoValue = toMongo[Map[String, String]](JsonBenchmark.bigMap) 54 | assert(mongoValue != null) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import pl.project13.scala.sbt.JmhPlugin 2 | 3 | lazy val scala212 = "2.12.20" 4 | lazy val scala213 = "2.13.16" 5 | lazy val scala3 = "3.3.5" 6 | 7 | // sbt-github-actions needs configuration in `ThisBuild` 8 | ThisBuild / crossScalaVersions := Seq(scala212, scala213, scala3) 9 | ThisBuild / scalaVersion := scala213 10 | ThisBuild / githubWorkflowPublishTargetBranches := List() 11 | ThisBuild / githubWorkflowJavaVersions := List(JavaSpec.temurin("21")) 12 | ThisBuild / githubWorkflowBuildPreamble ++= List( 13 | WorkflowStep.Sbt(List("scalafmtCheckAll"), name = Some("Check formatting")) 14 | ) 15 | ThisBuild / githubWorkflowBuildMatrixFailFast := Some(false) 16 | 17 | // workaround for CI because `sbt ++3.3.4 test` used by sbt-github-actions 18 | // still tries to compile the Scala 2 only projects leading to weird issues 19 | // note that `sbt +test` is working fine to run cross-compiled tests locally 20 | ThisBuild / githubWorkflowBuild := Seq( 21 | WorkflowStep.Sbt( 22 | commands = List("test"), 23 | name = Some("Build Scala 2 project"), 24 | cond = Some(s"matrix.scala != '$scala3'")), 25 | WorkflowStep.Sbt( 26 | commands = List("sphere-util/test", "sphere-json-core/test", "sphere-mongo-core/test"), 27 | name = Some("Build Scala 3 project"), 28 | cond = Some(s"matrix.scala == '$scala3'") 29 | ) 30 | ) 31 | 32 | // Release 33 | 34 | inThisBuild( 35 | List( 36 | organization := "com.commercetools", 37 | licenses += ("Apache-2.0", url("https://www.apache.org/licenses/LICENSE-2.0.html")), 38 | homepage := Some(url("https://github.com/commercetools/sphere-scala-libs")), 39 | developers := List( 40 | Developer( 41 | id = "commercetools", 42 | name = "commercetools", 43 | email = "ondemand@commercetools.com", 44 | url = url("https://commercetools.com"))), 45 | githubWorkflowTargetTags ++= Seq("v*"), 46 | githubWorkflowPublishTargetBranches := Seq(RefPredicate.StartsWith(Ref.Tag("v"))), 47 | githubWorkflowPublish := Seq(WorkflowStep.Sbt( 48 | List("ci-release"), 49 | env = Map( 50 | "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", 51 | "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", 52 | "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", 53 | "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}" 54 | ) 55 | )) 56 | )) 57 | 58 | lazy val standardSettings = Defaults.coreDefaultSettings ++ Seq( 59 | logBuffered := false, 60 | scalacOptions ++= Seq( 61 | "-deprecation", 62 | "-unchecked", 63 | "-feature" 64 | ), 65 | javacOptions ++= Seq("-deprecation", "-Xlint:unchecked"), 66 | // targets Java 8 bytecode (scalac & javac) 67 | scalacOptions ++= { 68 | if (scalaVersion.value.startsWith("2.12") || scalaVersion.value.startsWith("3")) Seq.empty 69 | else Seq("-target", "8") 70 | }, 71 | ThisBuild / javacOptions ++= Seq("-source", "8", "-target", "8"), 72 | Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oDF"), 73 | libraryDependencies ++= Seq( 74 | "org.scalatest" %% "scalatest" % "3.2.19" % Test, 75 | "org.scalatestplus" %% "scalacheck-1-16" % "3.2.14.0" % Test, 76 | "org.scalacheck" %% "scalacheck" % "1.18.1" % Test, 77 | "ch.qos.logback" % "logback-classic" % "1.5.18" % Test 78 | ), 79 | ThisBuild / shellPrompt := { state ⇒ 80 | scala.Console.CYAN + Project.extract(state).currentRef.project + "> " + scala.Console.RESET 81 | } 82 | ) 83 | 84 | lazy val `sphere-libs` = project 85 | .in(file(".")) 86 | .settings(standardSettings: _*) 87 | .settings(publishArtifact := false, publish := {}, crossScalaVersions := Seq()) 88 | .aggregate( 89 | `sphere-util`, 90 | `sphere-json`, 91 | `sphere-json-core`, 92 | `sphere-json-derivation`, 93 | `sphere-mongo`, 94 | `sphere-mongo-core`, 95 | `sphere-mongo-derivation`, 96 | `sphere-mongo-derivation-magnolia`, 97 | `benchmarks` 98 | ) 99 | 100 | lazy val `sphere-util` = project 101 | .in(file("./util")) 102 | .settings(standardSettings: _*) 103 | .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) 104 | .settings(homepage := Some(url("https://github.com/commercetools/sphere-scala-libs/README.md"))) 105 | 106 | lazy val `sphere-json-core` = project 107 | .in(file("./json/json-core")) 108 | .settings(standardSettings: _*) 109 | .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) 110 | .dependsOn(`sphere-util`) 111 | 112 | lazy val `sphere-json-derivation` = project 113 | .in(file("./json/json-derivation")) 114 | .settings(standardSettings: _*) 115 | .settings(Fmpp.settings: _*) 116 | .settings(crossScalaVersions := Seq(scala212, scala213)) 117 | .dependsOn(`sphere-json-core`) 118 | 119 | lazy val `sphere-json` = project 120 | .in(file("./json")) 121 | .settings(standardSettings: _*) 122 | .settings(homepage := Some( 123 | url("https://github.com/commercetools/sphere-scala-libs/blob/master/json/README.md"))) 124 | .settings(crossScalaVersions := Seq(scala212, scala213)) 125 | .dependsOn(`sphere-json-core`, `sphere-json-derivation`) 126 | 127 | lazy val `sphere-mongo-core` = project 128 | .in(file("./mongo/mongo-core")) 129 | .settings(standardSettings: _*) 130 | .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) 131 | .dependsOn(`sphere-util`) 132 | 133 | lazy val `sphere-mongo-derivation` = project 134 | .in(file("./mongo/mongo-derivation")) 135 | .settings(standardSettings: _*) 136 | .settings(Fmpp.settings: _*) 137 | .settings(crossScalaVersions := Seq(scala212, scala213)) 138 | .dependsOn(`sphere-mongo-core`) 139 | 140 | lazy val `sphere-mongo-derivation-magnolia` = project 141 | .in(file("./mongo/mongo-derivation-magnolia")) 142 | .settings(standardSettings: _*) 143 | .settings(crossScalaVersions := Seq(scala212, scala213)) 144 | .dependsOn(`sphere-mongo-core`) 145 | 146 | lazy val `sphere-mongo` = project 147 | .in(file("./mongo")) 148 | .settings(standardSettings: _*) 149 | .settings(homepage := Some( 150 | url("https://github.com/commercetools/sphere-scala-libs/blob/master/mongo/README.md"))) 151 | .settings(crossScalaVersions := Seq(scala212, scala213)) 152 | .dependsOn(`sphere-mongo-core`, `sphere-mongo-derivation`) 153 | 154 | // benchmarks 155 | 156 | lazy val benchmarks = project 157 | .settings(standardSettings: _*) 158 | .settings(publishArtifact := false, publish := {}) 159 | .settings(crossScalaVersions := Seq(scala212, scala213)) 160 | .enablePlugins(JmhPlugin) 161 | .dependsOn(`sphere-util`, `sphere-json`, `sphere-mongo`) 162 | -------------------------------------------------------------------------------- /json/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2013 commercetools GmbH 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /json/json-core/dependencies.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies ++= Seq( 2 | "org.json4s" %% "json4s-jackson" % "4.0.7", 3 | "com.fasterxml.jackson.core" % "jackson-databind" % "2.19.0", 4 | "org.typelevel" %% "cats-core" % "2.13.0" 5 | ) 6 | -------------------------------------------------------------------------------- /json/json-core/src/main/scala/io/sphere/json/JSON.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.json4s.JsonAST.JValue 4 | 5 | import scala.annotation.implicitNotFound 6 | 7 | @implicitNotFound("Could not find an instance of JSON for ${A}") 8 | trait JSON[A] extends FromJSON[A] with ToJSON[A] 9 | 10 | object JSON extends JSONInstances with JSONLowPriorityImplicits { 11 | @inline def apply[A](implicit instance: JSON[A]): JSON[A] = instance 12 | } 13 | 14 | trait JSONLowPriorityImplicits { 15 | implicit def fromJSONAndToJSON[A](implicit fromJSON: FromJSON[A], toJSON: ToJSON[A]): JSON[A] = 16 | new JSON[A] { 17 | override def read(jval: JValue): JValidation[A] = fromJSON.read(jval) 18 | override def write(value: A): JValue = toJSON.write(value) 19 | } 20 | } 21 | 22 | class JSONException(msg: String) extends RuntimeException(msg) 23 | 24 | sealed abstract class JSONError 25 | case class JSONFieldError(path: List[String], message: String) extends JSONError { 26 | override def toString = path.mkString(" -> ") + ": " + message 27 | } 28 | case class JSONParseError(message: String) extends JSONError { 29 | override def toString = message 30 | } 31 | -------------------------------------------------------------------------------- /json/json-core/src/main/scala/io/sphere/json/SphereJsonParser.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature.{ 4 | USE_BIG_DECIMAL_FOR_FLOATS, 5 | USE_BIG_INTEGER_FOR_INTS 6 | } 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import org.json4s.jackson.{Json4sScalaModule, JsonMethods} 9 | 10 | // extends the default JsonMethods to configure a different default jackson parser 11 | private object SphereJsonParser extends JsonMethods { 12 | override val mapper: ObjectMapper = { 13 | val m = new ObjectMapper() 14 | m.registerModule(new Json4sScalaModule) 15 | m.configure(USE_BIG_INTEGER_FOR_INTS, false) 16 | m.configure(USE_BIG_DECIMAL_FOR_FLOATS, false) 17 | m 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /json/json-core/src/main/scala/io/sphere/json/catsinstances/package.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import _root_.cats.{Contravariant, Functor, Invariant} 4 | import org.json4s.JValue 5 | 6 | /** Cats instances for [[JSON]], [[FromJSON]] and [[ToJSON]] 7 | */ 8 | package object catsinstances extends JSONInstances with FromJSONInstances with ToJSONInstances 9 | 10 | trait JSONInstances { 11 | implicit val catsInvariantForJSON: Invariant[JSON] = new JSONInvariant 12 | } 13 | 14 | trait FromJSONInstances { 15 | implicit val catsFunctorForFromJSON: Functor[FromJSON] = new FromJSONFunctor 16 | } 17 | 18 | trait ToJSONInstances { 19 | implicit val catsContravariantForToJSON: Contravariant[ToJSON] = new ToJSONContravariant 20 | } 21 | 22 | class JSONInvariant extends Invariant[JSON] { 23 | override def imap[A, B](fa: JSON[A])(f: A => B)(g: B => A): JSON[B] = new JSON[B] { 24 | override def write(b: B): JValue = fa.write(g(b)) 25 | override def read(jval: JValue): JValidation[B] = fa.read(jval).map(f) 26 | override val fields: Set[String] = fa.fields 27 | } 28 | } 29 | 30 | class FromJSONFunctor extends Functor[FromJSON] { 31 | override def map[A, B](fa: FromJSON[A])(f: A => B): FromJSON[B] = new FromJSON[B] { 32 | override def read(jval: JValue): JValidation[B] = fa.read(jval).map(f) 33 | override val fields: Set[String] = fa.fields 34 | } 35 | } 36 | 37 | class ToJSONContravariant extends Contravariant[ToJSON] { 38 | override def contramap[A, B](fa: ToJSON[A])(f: B => A): ToJSON[B] = new ToJSON[B] { 39 | override def write(b: B): JValue = fa.write(f(b)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /json/json-core/src/main/scala/io/sphere/json/package.scala: -------------------------------------------------------------------------------- 1 | package io.sphere 2 | 3 | import cats.data.Validated.{Invalid, Valid} 4 | import cats.data.{NonEmptyList, ValidatedNel} 5 | import com.fasterxml.jackson.core.JsonParseException 6 | import com.fasterxml.jackson.core.exc.{InputCoercionException, StreamConstraintsException} 7 | import com.fasterxml.jackson.databind.JsonMappingException 8 | import io.sphere.util.Logging 9 | import org.json4s.{DefaultFormats, JsonInput, StringInput} 10 | import org.json4s.JsonAST._ 11 | import org.json4s.ParserUtil.ParseException 12 | import org.json4s.jackson.compactJson 13 | import java.time.format.DateTimeFormatter 14 | 15 | /** Provides functions for reading & writing JSON, via type classes JSON/JSONR/JSONW. */ 16 | package object json extends Logging { 17 | 18 | private[json] val JavaYearMonthFormatter = 19 | DateTimeFormatter.ofPattern("uuuu-MM") 20 | 21 | implicit val liftJsonFormats: DefaultFormats = DefaultFormats 22 | 23 | type JValidation[A] = ValidatedNel[JSONError, A] 24 | 25 | def parseJsonUnsafe(json: JsonInput): JValue = 26 | SphereJsonParser.parse(json, useBigDecimalForDouble = false, useBigIntForLong = false) 27 | 28 | def parseJSON(json: JsonInput): JValidation[JValue] = 29 | try Valid(parseJsonUnsafe(json)) 30 | catch { 31 | case e: ParseException => jsonParseError(e.getMessage) 32 | case e: JsonMappingException => jsonParseError(e.getOriginalMessage) 33 | case e: JsonParseException => jsonParseError(e.getOriginalMessage) 34 | case e: InputCoercionException => jsonParseError(e.getOriginalMessage) 35 | case e: StreamConstraintsException => jsonParseError(e.getOriginalMessage) 36 | } 37 | 38 | def parseJSON(json: String): JValidation[JValue] = 39 | parseJSON(StringInput(json)) 40 | 41 | def jsonParseError[A](msg: String): Invalid[NonEmptyList[JSONError]] = 42 | Invalid(NonEmptyList.one(JSONParseError(msg))) 43 | 44 | def fromJSON[A: FromJSON](json: JsonInput): JValidation[A] = 45 | parseJSON(json).andThen(fromJValue[A]) 46 | 47 | def fromJSON[A: FromJSON](json: String): JValidation[A] = 48 | parseJSON(json).andThen(fromJValue[A]) 49 | 50 | private val jNothingStr = "{}" 51 | 52 | def toJSON[A: ToJSON](a: A): String = toJValue(a) match { 53 | case JNothing => jNothingStr 54 | case jval => compactJson(jval) 55 | } 56 | 57 | /** Parses a JSON string into a type A. Throws a [[JSONException]] on failure. 58 | * 59 | * @param json 60 | * The JSON string to parse. 61 | * @return 62 | * An instance of type A. 63 | */ 64 | def getFromJSON[A: FromJSON](json: JsonInput): A = 65 | getFromJValue[A](parseJsonUnsafe(json)) 66 | 67 | def getFromJSON[A: FromJSON](json: String): A = 68 | getFromJSON(StringInput(json)) 69 | 70 | def fromJValue[A](jval: JValue)(implicit json: FromJSON[A]): JValidation[A] = 71 | json.read(jval) 72 | 73 | def toJValue[A](a: A)(implicit json: ToJSON[A]): JValue = 74 | json.write(a) 75 | 76 | def getFromJValue[A: FromJSON](jval: JValue): A = 77 | fromJValue[A](jval) match { 78 | case Valid(a) => a 79 | case Invalid(errs) => throw new JSONException(errs.toList.mkString(", ")) 80 | } 81 | 82 | /** Extracts a JSON value of type A from a named field of a JSON object. 83 | * 84 | * @param name 85 | * The name of the field. 86 | * @param jObject 87 | * The JObject from which to extract the field. 88 | * @return 89 | * A success with a value of type A or a non-empty list of errors. 90 | */ 91 | def field[A]( 92 | name: String, 93 | default: Option[A] = None 94 | )(jObject: JObject)(implicit jsonr: FromJSON[A]): JValidation[A] = { 95 | val fields = jObject.obj 96 | // Perf note: avoiding Some(f) with fields.indexWhere and then constant time access is not faster 97 | fields.find(f => f._1 == name && f._2 != JNull && f._2 != JNothing) match { 98 | case Some(f) => 99 | jsonr 100 | .read(f._2) 101 | .leftMap(errs => 102 | errs.map { 103 | case JSONParseError(msg) => JSONFieldError(name :: Nil, msg) 104 | case JSONFieldError(path, msg) => JSONFieldError(name :: path, msg) 105 | }) 106 | case None => 107 | default 108 | .map(Valid(_)) 109 | .orElse( 110 | jsonr.read(JNothing).fold(_ => None, x => Some(Valid(x))) 111 | ) // orElse(jsonr.default) 112 | .getOrElse( 113 | Invalid(NonEmptyList.one(JSONFieldError(name :: Nil, "Missing required value")))) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/BigNumberParsingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | 6 | class BigNumberParsingSpec extends AnyWordSpec with Matchers { 7 | import BigNumberParsingSpec._ 8 | 9 | "parsing a big number" should { 10 | "not take much time when parsed as Double" in { 11 | fromJSON[Double](bigNumberAsString).isValid should be(false) 12 | } 13 | "not take much time when parsed as Long" in { 14 | fromJSON[Long](bigNumberAsString).isValid should be(false) 15 | } 16 | "not take much time when parsed as Int" in { 17 | fromJSON[Int](bigNumberAsString).isValid should be(false) 18 | } 19 | } 20 | } 21 | 22 | object BigNumberParsingSpec { 23 | private val bigNumberAsString = "9" * 10000000 24 | } 25 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/JSONProperties.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import scala.language.higherKinds 4 | import io.sphere.util.Money 5 | import java.util.{Currency, Locale, UUID} 6 | 7 | import cats.Eq 8 | import cats.data.NonEmptyList 9 | import cats.syntax.eq._ 10 | import org.joda.time._ 11 | import org.scalacheck._ 12 | import java.time 13 | 14 | import scala.math.BigDecimal.RoundingMode 15 | 16 | object JSONProperties extends Properties("JSON") { 17 | private def check[A: FromJSON: ToJSON: Eq](a: A): Boolean = { 18 | val json = s"""[${toJSON(a)}]""" 19 | val result = fromJSON[Seq[A]](json).toOption.map(_.head).get 20 | val r = result === a 21 | if (!r) println(s"result: $result - expected: $a") 22 | r 23 | } 24 | 25 | implicit def arbitraryVector[A: Arbitrary]: Arbitrary[Vector[A]] = 26 | Arbitrary(Arbitrary.arbitrary[List[A]].map(_.toVector)) 27 | 28 | implicit def arbitraryNEL[A: Arbitrary]: Arbitrary[NonEmptyList[A]] = 29 | Arbitrary(for { 30 | a <- Arbitrary.arbitrary[A] 31 | l <- Arbitrary.arbitrary[List[A]] 32 | } yield NonEmptyList(a, l)) 33 | 34 | implicit def arbitraryCurrency: Arbitrary[Currency] = 35 | Arbitrary(Gen 36 | .oneOf(Currency.getInstance("EUR"), Currency.getInstance("USD"), Currency.getInstance("JPY"))) 37 | 38 | implicit def arbitraryLocale: Arbitrary[Locale] = { 39 | // Filter because OS X thinks that 'C' and 'POSIX' are valid locales... 40 | val locales = Locale.getAvailableLocales().filter(_.toLanguageTag() != "und") 41 | Arbitrary(for { 42 | i <- Gen.choose(0, locales.length - 1) 43 | } yield locales(i)) 44 | } 45 | 46 | implicit def arbitraryDateTime: Arbitrary[DateTime] = 47 | Arbitrary(for { 48 | y <- Gen.choose(-4000, 4000) 49 | m <- Gen.choose(1, 12) 50 | d <- Gen.choose(1, 28) 51 | h <- Gen.choose(0, 23) 52 | min <- Gen.choose(0, 59) 53 | s <- Gen.choose(0, 59) 54 | ms <- Gen.choose(0, 999) 55 | } yield new DateTime(y, m, d, h, min, s, ms, DateTimeZone.UTC)) 56 | 57 | // generate dates between years -4000 and +4000 58 | implicit val javaInstant: Arbitrary[time.Instant] = 59 | Arbitrary(Gen.choose(-188395027761000L, 64092207599999L).map(time.Instant.ofEpochMilli(_))) 60 | 61 | implicit val javaLocalTime: Arbitrary[time.LocalTime] = Arbitrary( 62 | Gen.choose(0, 3600 * 24).map(time.LocalTime.ofSecondOfDay(_))) 63 | 64 | implicit def arbitraryDate: Arbitrary[LocalDate] = 65 | Arbitrary(Arbitrary.arbitrary[DateTime].map(_.toLocalDate)) 66 | 67 | implicit def arbitraryTime: Arbitrary[LocalTime] = 68 | Arbitrary(Arbitrary.arbitrary[DateTime].map(_.toLocalTime)) 69 | 70 | implicit def arbitraryYearMonth: Arbitrary[YearMonth] = 71 | Arbitrary(Arbitrary.arbitrary[DateTime].map(dt => new YearMonth(dt.getYear, dt.getMonthOfYear))) 72 | 73 | implicit def arbitraryMoney: Arbitrary[Money] = 74 | Arbitrary(for { 75 | c <- Arbitrary.arbitrary[Currency] 76 | i <- Arbitrary.arbitrary[Int] 77 | } yield Money.fromDecimalAmount(i, c)(RoundingMode.HALF_EVEN)) 78 | 79 | implicit def arbitraryUUID: Arbitrary[UUID] = 80 | Arbitrary(for { 81 | most <- Arbitrary.arbitrary[Long] 82 | least <- Arbitrary.arbitrary[Long] 83 | } yield new UUID(most, least)) 84 | 85 | implicit val currencyEqual: Eq[Currency] = new Eq[Currency] { 86 | def eqv(c1: Currency, c2: Currency) = c1.getCurrencyCode == c2.getCurrencyCode 87 | } 88 | implicit val localeEqual: Eq[Locale] = new Eq[Locale] { 89 | def eqv(l1: Locale, l2: Locale) = l1.toLanguageTag == l2.toLanguageTag 90 | } 91 | implicit val moneyEqual: Eq[Money] = new Eq[Money] { 92 | override def eqv(x: Money, y: Money): Boolean = x == y 93 | } 94 | implicit val dateTimeEqual: Eq[DateTime] = new Eq[DateTime] { 95 | def eqv(dt1: DateTime, dt2: DateTime) = dt1 == dt2 96 | } 97 | implicit val localTimeEqual: Eq[LocalTime] = new Eq[LocalTime] { 98 | def eqv(dt1: LocalTime, dt2: LocalTime) = dt1 == dt2 99 | } 100 | implicit val localDateEqual: Eq[LocalDate] = new Eq[LocalDate] { 101 | def eqv(dt1: LocalDate, dt2: LocalDate) = dt1 == dt2 102 | } 103 | implicit val yearMonthEqual: Eq[YearMonth] = new Eq[YearMonth] { 104 | def eqv(dt1: YearMonth, dt2: YearMonth) = dt1 == dt2 105 | } 106 | implicit val javaInstantEqual: Eq[time.Instant] = Eq.fromUniversalEquals 107 | implicit val javaLocalDateEqual: Eq[time.LocalDate] = Eq.fromUniversalEquals 108 | implicit val javaLocalTimeEqual: Eq[time.LocalTime] = Eq.fromUniversalEquals 109 | implicit val javaYearMonthEqual: Eq[time.YearMonth] = Eq.fromUniversalEquals 110 | 111 | private def checkC[C[_]](name: String)(implicit 112 | jri: FromJSON[C[Int]], 113 | jwi: ToJSON[C[Int]], 114 | arbi: Arbitrary[C[Int]], 115 | eqi: Eq[C[Int]], 116 | jrs: FromJSON[C[Short]], 117 | jws: ToJSON[C[Short]], 118 | arbs: Arbitrary[C[Short]], 119 | eqs: Eq[C[Short]], 120 | jrl: FromJSON[C[Long]], 121 | jwl: ToJSON[C[Long]], 122 | arbl: Arbitrary[C[Long]], 123 | eql: Eq[C[Long]], 124 | jrss: FromJSON[C[String]], 125 | jwss: ToJSON[C[String]], 126 | arbss: Arbitrary[C[String]], 127 | eqss: Eq[C[String]], 128 | jrf: FromJSON[C[Float]], 129 | jwf: ToJSON[C[Float]], 130 | arbf: Arbitrary[C[Float]], 131 | eqf: Eq[C[Float]], 132 | jrd: FromJSON[C[Double]], 133 | jwd: ToJSON[C[Double]], 134 | arbd: Arbitrary[C[Double]], 135 | eqd: Eq[C[Double]], 136 | jrb: FromJSON[C[Boolean]], 137 | jwb: ToJSON[C[Boolean]], 138 | arbb: Arbitrary[C[Boolean]], 139 | eqb: Eq[C[Boolean]] 140 | ) = { 141 | property(s"read/write $name of Ints") = Prop.forAll((l: C[Int]) => check(l)) 142 | property(s"read/write $name of Shorts") = Prop.forAll((l: C[Short]) => check(l)) 143 | property(s"read/write $name of Longs") = Prop.forAll((l: C[Long]) => check(l)) 144 | property(s"read/write $name of Strings") = Prop.forAll((l: C[String]) => check(l)) 145 | property(s"read/write $name of Floats") = Prop.forAll((l: C[Float]) => check(l)) 146 | property(s"read/write $name of Doubles") = Prop.forAll((l: C[Double]) => check(l)) 147 | property(s"read/write $name of Booleans") = Prop.forAll((l: C[Boolean]) => check(l)) 148 | } 149 | 150 | checkC[List]("List") 151 | checkC[Vector]("Vector") 152 | checkC[Set]("Set") 153 | checkC[NonEmptyList]("NonEmptyList") 154 | checkC[Option]("Option") 155 | checkC[({ type l[v] = Map[String, v] })#l]("Map") 156 | 157 | property("read/write Unit") = Prop.forAll((u: Unit) => check(u)) 158 | property("read/write Currency") = Prop.forAll((c: Currency) => check(c)) 159 | property("read/write Money") = Prop.forAll((m: Money) => check(m)) 160 | property("read/write Locale") = Prop.forAll((l: Locale) => check(l)) 161 | property("read/write UUID") = Prop.forAll((u: UUID) => check(u)) 162 | property("read/write DateTime") = Prop.forAll((u: DateTime) => check(u)) 163 | property("read/write LocalDate") = Prop.forAll((u: LocalDate) => check(u)) 164 | property("read/write LocalTime") = Prop.forAll((u: LocalTime) => check(u)) 165 | property("read/write YearMonth") = Prop.forAll((u: YearMonth) => check(u)) 166 | property("read/write java.time.Instant") = Prop.forAll((i: time.Instant) => check(i)) 167 | property("read/write java.time.LocalDate") = Prop.forAll((d: time.LocalDate) => check(d)) 168 | property("read/write java.time.LocalTime") = Prop.forAll((t: time.LocalTime) => check(t)) 169 | property("read/write java.time.YearMonth") = Prop.forAll((ym: time.YearMonth) => check(ym)) 170 | } 171 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/JSONSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.json4s.JsonAST.JValue 4 | import org.scalatest.matchers.must.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | 7 | class JSONSpec extends AnyWordSpec with Matchers { 8 | import JSONSpec._ 9 | "JSON.apply" must { 10 | "find possible JSON instance" in { 11 | implicit val testJson: JSON[Test] = new JSON[Test] { 12 | override def read(jval: JValue): JValidation[Test] = ??? 13 | override def write(value: Test): JValue = ??? 14 | } 15 | JSON[Test] must be(testJson) 16 | } 17 | "create instance from FromJSON and ToJSON" in { 18 | JSON[Int] 19 | JSON[List[Double]] 20 | JSON[Map[String, Int]] 21 | } 22 | } 23 | } 24 | 25 | object JSONSpec { 26 | case class Test(a: String) 27 | } 28 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/JodaJavaLocalDateCompatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.json4s.JString 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import java.time.Instant 7 | import cats.data.Validated.Valid 8 | 9 | class JodaJavaLocalDateCompatSpec extends AnyWordSpec with Matchers { 10 | 11 | val jodaReader = FromJSON.dateReader 12 | val javaReader = FromJSON.javaLocalDateReader 13 | def jsonDateStringWith( 14 | year: String = "2035", 15 | dayOfTheMonth: String = "23", 16 | monthOfTheYear: String = "11"): JString = JString(s"$year-$monthOfTheYear-${dayOfTheMonth}") 17 | 18 | private def test(value: JString) = 19 | (jodaReader.read(value), javaReader.read(value)) match { 20 | case (Valid(jodaDate), Valid(javaDate)) => 21 | jodaDate.getYear shouldBe javaDate.getYear 22 | jodaDate.getMonthOfYear shouldBe javaDate.getMonthValue 23 | jodaDate.getDayOfMonth shouldBe javaDate.getDayOfMonth 24 | case (jodaDate, javaDate) => 25 | fail(s"invalid date. joda: $jodaDate, java: $javaDate") 26 | } 27 | 28 | "parsing a LocalDate" should { 29 | "accept two digit years" in { 30 | test(jsonDateStringWith(year = "50")) 31 | } 32 | "accept year zero" in { 33 | test(JString("0-10-31")) 34 | } 35 | "accept no day set" in { 36 | test(JString("2024-09")) 37 | } 38 | "accept up to nine digit years" in { 39 | (1 to 9).foreach { l => 40 | val year = List.fill(l)("1").mkString("") 41 | test(jsonDateStringWith(year = year)) 42 | } 43 | } 44 | "accept a year with leading zero" in { 45 | test(jsonDateStringWith(year = "02020")) 46 | } 47 | "accept a year with leading plus sign" in { 48 | test(jsonDateStringWith(year = "+02020")) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.joda.time.DateTime 4 | import org.joda.time.DateTimeZone 5 | import org.joda.time.LocalDate 6 | import org.joda.time.LocalTime 7 | import org.joda.time.YearMonth 8 | import org.scalacheck.Arbitrary 9 | import org.scalacheck.Gen 10 | import org.scalacheck.Prop 11 | import org.scalacheck.Properties 12 | import org.scalatest.matchers.should.Matchers 13 | import org.scalatest.wordspec.AnyWordSpec 14 | 15 | import java.time.{Instant => JInstant} 16 | import java.time.{LocalDate => JLocalDate} 17 | import java.time.{LocalTime => JLocalTime} 18 | import java.time.{YearMonth => JYearMonth} 19 | import cats.data.Validated 20 | 21 | class JodaJavaTimeCompat extends Properties("Joda - java.time compat") { 22 | val epochMillis = Gen.choose(-188395027761000L, 64092207599999L) 23 | 24 | implicit def arbitraryDateTime: Arbitrary[DateTime] = 25 | Arbitrary(epochMillis.map(new DateTime(_, DateTimeZone.UTC))) 26 | 27 | // generate dates between years -4000 and +4000 28 | implicit val javaInstant: Arbitrary[JInstant] = 29 | Arbitrary(epochMillis.map(JInstant.ofEpochMilli(_))) 30 | 31 | implicit val javaLocalTime: Arbitrary[JLocalTime] = Arbitrary( 32 | Gen.choose(0, 3600 * 24 - 1).map(JLocalTime.ofSecondOfDay(_))) 33 | 34 | property("compatibility between serialized Instant and DateTime") = Prop.forAll { 35 | (instant: JInstant) => 36 | val dateTime = new DateTime(instant.toEpochMilli(), DateTimeZone.UTC) 37 | val serializedInstant = ToJSON[JInstant].write(instant) 38 | val serializedDateTime = ToJSON[DateTime].write(dateTime) 39 | serializedInstant == serializedDateTime 40 | } 41 | 42 | property("compatibility between serialized java.time.LocalTime and org.joda.time.LocalTime") = 43 | Prop.forAll { (javaTime: JLocalTime) => 44 | val jodaTime = LocalTime.fromMillisOfDay(javaTime.toNanoOfDay() / 1000000) 45 | val serializedJavaTime = ToJSON[JLocalTime].write(javaTime) 46 | val serializedJodaTime = ToJSON[LocalTime].write(jodaTime) 47 | serializedJavaTime == serializedJodaTime 48 | } 49 | 50 | property("roundtrip from java.time.Instant") = Prop.forAll { (instant: JInstant) => 51 | FromJSON[DateTime] 52 | .read(ToJSON[JInstant].write(instant)) 53 | .andThen { dateTime => 54 | FromJSON[JInstant].read(ToJSON[DateTime].write(dateTime)) 55 | } 56 | .fold(_ => false, _ == instant) 57 | } 58 | 59 | property("roundtrip from org.joda.time.DateTime") = Prop.forAll { (dateTime: DateTime) => 60 | FromJSON[JInstant] 61 | .read(ToJSON[DateTime].write(dateTime)) 62 | .andThen { instant => 63 | FromJSON[DateTime].read(ToJSON[JInstant].write(instant)) 64 | } 65 | .fold(_ => false, _ == dateTime) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import java.util.Currency 4 | 5 | import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} 6 | import cats.data.Validated.Valid 7 | import org.json4s.jackson.compactJson 8 | import org.scalatest.matchers.should.Matchers 9 | import org.scalatest.wordspec.AnyWordSpec 10 | 11 | class MoneyMarshallingSpec extends AnyWordSpec with Matchers { 12 | "money encoding/decoding" should { 13 | "be symmetric" in { 14 | val money = Money.EUR(34.56) 15 | val jsonAst = toJValue(money) 16 | val jsonAsString = compactJson(jsonAst) 17 | val Valid(readAst) = parseJSON(jsonAsString) 18 | 19 | jsonAst should equal(readAst) 20 | } 21 | 22 | "decode with type info" in { 23 | val json = 24 | """ 25 | { 26 | "type" : "centPrecision", 27 | "currencyCode" : "USD", 28 | "centAmount" : 3298 29 | } 30 | """ 31 | 32 | fromJSON[BaseMoney](json) should be(Valid(Money.USD(BigDecimal("32.98")))) 33 | } 34 | 35 | "decode without type info" in { 36 | val json = 37 | """ 38 | { 39 | "currencyCode" : "USD", 40 | "centAmount" : 3298 41 | } 42 | """ 43 | 44 | fromJSON[BaseMoney](json) should be(Valid(Money.USD(BigDecimal("32.98")))) 45 | } 46 | } 47 | 48 | "High precision money encoding/decoding" should { 49 | "be symmetric" in { 50 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 51 | 52 | val money = HighPrecisionMoney.fromDecimalAmount(34.123456, 6, Currency.getInstance("EUR")) 53 | val jsonAst = toJValue(money) 54 | val jsonAsString = compactJson(jsonAst) 55 | val Valid(readAst) = parseJSON(jsonAsString) 56 | val Valid(decodedMoney) = fromJSON[HighPrecisionMoney](jsonAsString) 57 | val Valid(decodedBaseMoney) = fromJSON[BaseMoney](jsonAsString) 58 | 59 | jsonAst should equal(readAst) 60 | decodedMoney should equal(money) 61 | decodedBaseMoney should equal(money) 62 | } 63 | 64 | "decode with type info" in { 65 | val json = 66 | """ 67 | { 68 | "type": "highPrecision", 69 | "currencyCode": "USD", 70 | "preciseAmount": 42, 71 | "fractionDigits": 4 72 | } 73 | """ 74 | 75 | fromJSON[BaseMoney](json) should be( 76 | Valid(HighPrecisionMoney.USD(BigDecimal("0.0042"), Some(4)))) 77 | } 78 | 79 | "decode with centAmount" in { 80 | val Valid(json) = parseJSON(""" 81 | { 82 | "type": "highPrecision", 83 | "currencyCode": "USD", 84 | "preciseAmount": 42, 85 | "centAmount": 1, 86 | "fractionDigits": 4 87 | } 88 | """) 89 | 90 | val Valid(parsed) = fromJValue[BaseMoney](json) 91 | 92 | toJValue(parsed) should be(json) 93 | } 94 | 95 | "validate data when decoded from JSON" in { 96 | val json = 97 | """ 98 | { 99 | "type": "highPrecision", 100 | "currencyCode": "USD", 101 | "preciseAmount": 42, 102 | "fractionDigits": 1 103 | } 104 | """ 105 | 106 | fromJSON[BaseMoney](json).isValid should be(false) 107 | } 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/SetHandlingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.scalatest.matchers.must.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | 6 | class SetHandlingSpec extends AnyWordSpec with Matchers { 7 | "JSON deserialization" must { 8 | 9 | "should accept same elements in array to create a set" in { 10 | val jeans = getFromJSON[Set[String]](""" 11 | ["mobile", "mobile"] 12 | """) 13 | 14 | jeans must be(Set("mobile")) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/SphereJsonExample.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import io.sphere.json._ 4 | import org.json4s.{JObject, JValue} 5 | import org.scalatest.matchers.should.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | class SphereJsonExample extends AnyWordSpec with Matchers { 9 | 10 | case class User(name: String, age: Int, location: String) 11 | 12 | object User { 13 | 14 | // update https://github.com/commercetools/sphere-scala-libs/blob/master/json/README.md in case of changed 15 | implicit val json: JSON[User] = new JSON[User] { 16 | import cats.data.ValidatedNel 17 | import cats.syntax.apply._ 18 | 19 | def read(jval: JValue): ValidatedNel[JSONError, User] = jval match { 20 | case o: JObject => 21 | (field[String]("name")(o), field[Int]("age")(o), field[String]("location")(o)) 22 | .mapN(User.apply) 23 | case _ => fail("JSON object expected.") 24 | } 25 | 26 | def write(u: User): JValue = JObject( 27 | List( 28 | "name" -> toJValue(u.name), 29 | "age" -> toJValue(u.age), 30 | "location" -> toJValue(u.location) 31 | )) 32 | } 33 | } 34 | 35 | "JSON[User]" should { 36 | "serialize and deserialize an user" in { 37 | val user = User("name", 23, "earth") 38 | val json = toJSON(user) 39 | parseJSON(json).isValid should be(true) 40 | 41 | val newUser = getFromJSON[User](json) 42 | newUser should be(user) 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/SphereJsonParserSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.scalatest.matchers.must.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | 6 | class SphereJsonParserSpec extends AnyWordSpec with Matchers { 7 | "Object mapper" must { 8 | 9 | "accept strings with 20_000_000 bytes" in { 10 | SphereJsonParser.mapper.getFactory.streamReadConstraints().getMaxStringLength must be( 11 | 20000000) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/ToJSONSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import java.util.UUID 4 | 5 | import org.json4s._ 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class ToJSONSpec extends AnyWordSpec with Matchers { 10 | 11 | case class User(id: UUID, firstName: String, age: Int) 12 | 13 | "ToJSON.apply" must { 14 | "create a ToJSON" in { 15 | implicit val encodeUser: ToJSON[User] = ToJSON.instance[User](u => 16 | JObject( 17 | List( 18 | "id" -> toJValue(u.id), 19 | "first_name" -> toJValue(u.firstName), 20 | "age" -> toJValue(u.age) 21 | ))) 22 | 23 | val id = UUID.randomUUID() 24 | val json = toJValue(User(id, "bidule", 109)) 25 | json must be( 26 | JObject( 27 | List( 28 | "id" -> JString(id.toString), 29 | "first_name" -> JString("bidule"), 30 | "age" -> JLong(109) 31 | ))) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /json/json-core/src/test/scala/io/sphere/json/catsinstances/JSONCatsInstancesTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json.catsinstances 2 | 3 | import cats.syntax.invariant._ 4 | import cats.syntax.functor._ 5 | import cats.syntax.contravariant._ 6 | import io.sphere.json.JSON 7 | import io.sphere.json._ 8 | import org.json4s.JsonAST 9 | import org.json4s.JsonAST.JString 10 | import org.scalatest.matchers.must.Matchers 11 | import org.scalatest.wordspec.AnyWordSpec 12 | 13 | class JSONCatsInstancesTest extends AnyWordSpec with Matchers { 14 | import JSONCatsInstancesTest._ 15 | 16 | "Invariant[JSON]" must { 17 | "allow imaping a default format" in { 18 | val myId = MyId("test") 19 | val json = toJValue(myId) 20 | json must be(JString("test")) 21 | val myNewId = getFromJValue[MyId](json) 22 | myNewId must be(myId) 23 | } 24 | } 25 | 26 | "Functor[FromJson] and Contramap[ToJson]" must { 27 | "allow mapping and contramapping a default format" in { 28 | val myId = MyId2("test") 29 | val json = toJValue(myId) 30 | json must be(JString("test")) 31 | val myNewId = getFromJValue[MyId2](json) 32 | myNewId must be(myId) 33 | } 34 | } 35 | } 36 | 37 | object JSONCatsInstancesTest { 38 | private val stringJson: JSON[String] = new JSON[String] { 39 | override def write(value: String): JsonAST.JValue = ToJSON[String].write(value) 40 | override def read(jval: JsonAST.JValue): JValidation[String] = FromJSON[String].read(jval) 41 | } 42 | 43 | case class MyId(id: String) extends AnyVal 44 | object MyId { 45 | implicit val json: JSON[MyId] = stringJson.imap(MyId.apply)(_.id) 46 | } 47 | 48 | case class MyId2(id: String) extends AnyVal 49 | object MyId2 { 50 | implicit val fromJson: FromJSON[MyId2] = FromJSON[String].map(apply) 51 | implicit val toJson: ToJSON[MyId2] = ToJSON[String].contramap(_.id) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /json/json-derivation/src/main/java/io/sphere/json/annotations/JSONEmbedded.java: -------------------------------------------------------------------------------- 1 | package io.sphere.json.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | import static java.lang.annotation.ElementType.METHOD; 7 | 8 | /** Specifies to embed / flatten all attributes of a nested object into the parent object in JSON. 9 | * Among other things, this can be used to work around the 22 field limit of case classes without 10 | * causing unwanted nesting in the JSON. */ 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @Target({METHOD}) 13 | public @interface JSONEmbedded {} -------------------------------------------------------------------------------- /json/json-derivation/src/main/java/io/sphere/json/annotations/JSONIgnore.java: -------------------------------------------------------------------------------- 1 | package io.sphere.json.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | import static java.lang.annotation.ElementType.METHOD; 7 | 8 | /** Specifies to ignore a certain field on serialization. The field must have a default value. */ 9 | @Retention(RetentionPolicy.RUNTIME) 10 | @Target({METHOD}) 11 | public @interface JSONIgnore {} -------------------------------------------------------------------------------- /json/json-derivation/src/main/java/io/sphere/json/annotations/JSONKey.java: -------------------------------------------------------------------------------- 1 | package io.sphere.json.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | import static java.lang.annotation.ElementType.METHOD; 7 | 8 | /** Specifies the field name to use in JSON, instead of defaulting to the field name of the case class. */ 9 | @Retention(RetentionPolicy.RUNTIME) 10 | @Target({METHOD}) 11 | public @interface JSONKey { 12 | String value(); 13 | } -------------------------------------------------------------------------------- /json/json-derivation/src/main/java/io/sphere/json/annotations/JSONTypeHint.java: -------------------------------------------------------------------------------- 1 | package io.sphere.json.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | import static java.lang.annotation.ElementType.TYPE; 7 | 8 | /** Specifies a type hint used to choose the correct type when deserializing 9 | * types that form a type hierarchy using a delegate converter. */ 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Target({TYPE}) 12 | public @interface JSONTypeHint { 13 | String value() default ""; 14 | } -------------------------------------------------------------------------------- /json/json-derivation/src/main/java/io/sphere/json/annotations/JSONTypeHintField.java: -------------------------------------------------------------------------------- 1 | package io.sphere.json.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | import java.lang.annotation.Inherited; 7 | import static java.lang.annotation.ElementType.TYPE; 8 | 9 | /** Specifies name of the field that should be used as a type hint for delegate converters 10 | * when deserializing objects that form a class hierarchy. */ 11 | @Inherited 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Target({TYPE}) 14 | public @interface JSONTypeHintField { 15 | String value() default "type"; 16 | } -------------------------------------------------------------------------------- /json/json-derivation/src/main/scala/io/sphere/json/ToJSONProduct.fmpp.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import org.json4s._ 4 | 5 | object ToJSONProduct { 6 | 7 | def forProduct1[A, A1 : ToJSON]( 8 | f: A => (String, A1) 9 | ): ToJSON[A] = new ToJSON[A] { 10 | override def write(a: A): JValue = { 11 | val t = f(a) 12 | JObject((t._1 -> toJValue(t._2)) :: Nil) 13 | } 14 | } 15 | 16 | <#list 2..22 as i> 17 | <#assign implTypeParams><#list 1..i as j>A${j} : ToJSON<#if i !=j>, 18 | def forProduct${i}[A, ${implTypeParams}]( 19 | f: A => (<#list 1..i as j>(String, A${j})<#if i !=j>, ) 20 | ): ToJSON[A] = new ToJSON[A] { 21 | override def write(a: A): JValue = { 22 | val t = f(a) 23 | JObject( 24 | <#list 1..i as j>t._${j}._1 -> toJValue(t._${j}._2) :: Nil 25 | ) 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /json/json-derivation/src/main/scala/io/sphere/json/generic/JSONMacros.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | package generic 3 | 4 | import io.sphere.json.JSON 5 | 6 | import scala.reflect.macros.blackbox 7 | 8 | private[generic] object JSONMacros { 9 | private def collectKnownSubtypes(c: blackbox.Context)( 10 | s: c.universe.Symbol): Set[c.universe.Symbol] = 11 | if (s.isModule || s.isModuleClass) Set(s) 12 | else if (s.isClass) { 13 | val cs = s.asClass 14 | if (cs.isCaseClass) Set(cs) 15 | else if ((cs.isTrait || cs.isAbstract) && cs.isSealed) 16 | cs.knownDirectSubclasses.flatMap(collectKnownSubtypes(c)(_)) 17 | else Set.empty 18 | } else Set.empty 19 | 20 | def jsonProductApply(c: blackbox.Context)( 21 | tpe: c.universe.Type, 22 | classSym: c.universe.ClassSymbol): c.universe.Tree = { 23 | import c.universe._ 24 | 25 | if (classSym.isCaseClass && !classSym.isModuleClass) { 26 | val classSymType = classSym.toType 27 | val argList = classSymType.member(termNames.CONSTRUCTOR).asMethod.paramLists.head 28 | val modifiers = Modifiers(Flag.PARAM) 29 | val (argDefs, args) = (for ((a, i) <- argList.zipWithIndex) yield { 30 | val argType = classSymType.member(a.name).typeSignatureIn(tpe) 31 | val termName = TermName("x" + i) 32 | val argTree = ValDef(modifiers, termName, TypeTree(argType), EmptyTree) 33 | (argTree, Ident(termName)) 34 | }).unzip 35 | 36 | val applyBlock = Block( 37 | Nil, 38 | Function( 39 | argDefs, 40 | Apply(Select(Ident(classSym.companion), TermName("apply")), args) 41 | )) 42 | Apply( 43 | Select( 44 | reify(io.sphere.json.generic.`package`).tree, 45 | TermName("jsonProduct") 46 | ), 47 | applyBlock :: Nil 48 | ) 49 | } else if (classSym.isCaseClass && classSym.isModuleClass) { 50 | Apply( 51 | Select( 52 | reify(io.sphere.json.generic.`package`).tree, 53 | TermName("jsonProduct0") 54 | ), 55 | Ident(classSym.name.toTermName) :: Nil 56 | ) 57 | } else if (classSym.isModuleClass) { 58 | Apply( 59 | Select( 60 | reify(io.sphere.json.generic.`package`).tree, 61 | TermName("jsonSingleton") 62 | ), 63 | Ident(classSym.name.toTermName) :: Nil 64 | ) 65 | } else c.abort(c.enclosingPosition, "Not a case class or (case) object") 66 | } 67 | 68 | def deriveSingletonJSON_impl[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[JSON[A]] = { 69 | import c.universe._ 70 | 71 | val tpe = weakTypeOf[A] 72 | val symbol = tpe.typeSymbol 73 | 74 | def singletonTree(classSym: c.universe.ClassSymbol): Tree = 75 | if (classSym.isModuleClass) { 76 | Apply( 77 | Select( 78 | reify(io.sphere.json.generic.`package`).tree, 79 | TermName("jsonSingleton") 80 | ), 81 | Ident(classSym.name.toTermName) :: Nil 82 | ) 83 | } else c.abort(c.enclosingPosition, "Only case Objects are supported.") 84 | 85 | if (!symbol.isClass) 86 | c.abort(c.enclosingPosition, "Can only enumerate values of a sealed trait or class.") 87 | else if (!symbol.asClass.isSealed) 88 | c.abort(c.enclosingPosition, "Can only enumerate values of a sealed trait or class.") 89 | else { 90 | val subtypes = collectKnownSubtypes(c)(symbol) 91 | 92 | val idents = Ident(symbol.name) :: subtypes.map { s => 93 | if (s.isModuleClass) TypeTree(s.asClass.toType) else Ident(s.name) 94 | }.toList 95 | 96 | if (idents.size == 1) 97 | c.abort(c.enclosingPosition, "Subtypes not found.") 98 | else { 99 | val instanceDefs = subtypes.zipWithIndex.collect { 100 | case (symbol, i) if symbol.isClass && symbol.asClass.isModuleClass => 101 | if (symbol.asClass.typeParams.nonEmpty) 102 | c.abort( 103 | c.enclosingPosition, 104 | "Types with type parameters cannot (yet) be derived as part of a sum type") 105 | else { 106 | ValDef( 107 | Modifiers(Flag.IMPLICIT), 108 | TermName("json" + i), 109 | AppliedTypeTree( 110 | Ident(TypeName("JSON")), 111 | Ident(symbol) :: Nil 112 | ), 113 | singletonTree(symbol.asClass) 114 | ) 115 | } 116 | }.toList 117 | 118 | c.Expr[JSON[A]]( 119 | Block( 120 | instanceDefs, 121 | Apply( 122 | TypeApply( 123 | Select( 124 | reify(io.sphere.json.generic.`package`).tree, 125 | TermName("jsonSingletonEnumSwitch") 126 | ), 127 | idents 128 | ), 129 | reify(Nil).tree :: Nil 130 | ) 131 | ) 132 | ) 133 | } 134 | } 135 | } 136 | 137 | def deriveJSON_impl[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[JSON[A]] = { 138 | import c.universe._ 139 | 140 | val tpe = weakTypeOf[A] 141 | val symbol = tpe.typeSymbol 142 | 143 | if (tpe <:< weakTypeOf[Enumeration#Value]) { 144 | val TypeRef(pre, _, _) = tpe 145 | c.Expr[JSON[A]]( 146 | Apply( 147 | Select( 148 | reify(io.sphere.json.generic.`package`).tree, 149 | TermName("jsonEnum") 150 | ), 151 | Ident(pre.typeSymbol.name.toTermName) :: Nil 152 | )) 153 | } else if (symbol.isClass && (symbol.asClass.isCaseClass || symbol.asClass.isModuleClass)) 154 | // product type or singleton 155 | c.Expr[JSON[A]](jsonProductApply(c)(tpe, symbol.asClass)) 156 | else { 157 | // sum type 158 | if (!symbol.isClass) 159 | c.abort( 160 | c.enclosingPosition, 161 | "Can only enumerate values of a sealed trait or class." 162 | ) 163 | else if (!symbol.asClass.isSealed) 164 | c.abort( 165 | c.enclosingPosition, 166 | "Can only enumerate values of a sealed trait or class." 167 | ) 168 | else { 169 | val subtypes = collectKnownSubtypes(c)(symbol) 170 | val idents = Ident(symbol.name) :: subtypes.map { s => 171 | if (s.isModuleClass) New(TypeTree(s.asClass.toType)) else Ident(s.name) 172 | }.toList 173 | 174 | if (idents.size == 1) 175 | c.abort(c.enclosingPosition, "Subtypes not found.") 176 | else { 177 | val instanceDefs = subtypes.zipWithIndex.collect { 178 | case (symbol, i) if symbol.isClass && symbol.asClass.isCaseClass => 179 | if (symbol.asClass.typeParams.nonEmpty) { 180 | c.abort( 181 | c.enclosingPosition, 182 | "Types with type parameters cannot (yet) be derived as part of a sum type") 183 | } else { 184 | ValDef( 185 | Modifiers(Flag.IMPLICIT), 186 | TermName("json" + i), 187 | AppliedTypeTree( 188 | Ident(TypeName("JSON")), 189 | Ident(symbol) :: Nil 190 | ), 191 | jsonProductApply(c)(tpe, symbol.asClass) 192 | ) 193 | } 194 | }.toList 195 | 196 | c.Expr[JSON[A]]( 197 | Block( 198 | instanceDefs, 199 | Apply( 200 | TypeApply( 201 | Select( 202 | reify(io.sphere.json.generic.`package`).tree, 203 | TermName("jsonTypeSwitch") 204 | ), 205 | idents 206 | ), 207 | reify(Nil).tree :: Nil 208 | ) 209 | ) 210 | ) 211 | } 212 | } 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import cats.data.Validated.Valid 4 | import io.sphere.json.generic._ 5 | import org.json4s.JValue 6 | import org.json4s.JsonAST.JNothing 7 | import org.json4s.jackson.JsonMethods.{compact, render} 8 | import org.scalatest.matchers.must.Matchers 9 | import org.scalatest.wordspec.AnyWordSpec 10 | 11 | class DeriveSingletonJSONSpec extends AnyWordSpec with Matchers { 12 | "DeriveSingletonJSON" must { 13 | "read normal singleton values" in { 14 | val user = getFromJSON[UserWithPicture](""" 15 | { 16 | "userId": "foo-123", 17 | "pictureSize": "Medium", 18 | "pictureUrl": "http://exmple.com" 19 | } 20 | """) 21 | 22 | user must be(UserWithPicture("foo-123", Medium, "http://exmple.com")) 23 | } 24 | 25 | "fail to read if singleton value is unknown" in { 26 | a[JSONException] must be thrownBy getFromJSON[UserWithPicture](""" 27 | { 28 | "userId": "foo-123", 29 | "pictureSize": "foo", 30 | "pictureUrl": "http://exmple.com" 31 | } 32 | """) 33 | } 34 | 35 | "write normal singleton values" in { 36 | val userJson = toJValue(UserWithPicture("foo-123", Medium, "http://exmple.com")) 37 | 38 | val Valid(expectedJson) = parseJSON(""" 39 | { 40 | "userId": "foo-123", 41 | "pictureSize": "Medium", 42 | "pictureUrl": "http://exmple.com" 43 | } 44 | """) 45 | 46 | filter(userJson) must be(expectedJson) 47 | } 48 | 49 | "read custom singleton values" in { 50 | val user = getFromJSON[UserWithPicture](""" 51 | { 52 | "userId": "foo-123", 53 | "pictureSize": "bar", 54 | "pictureUrl": "http://exmple.com" 55 | } 56 | """) 57 | 58 | user must be(UserWithPicture("foo-123", Custom, "http://exmple.com")) 59 | } 60 | 61 | "write custom singleton values" in { 62 | val userJson = toJValue(UserWithPicture("foo-123", Custom, "http://exmple.com")) 63 | 64 | val Valid(expectedJson) = parseJSON(""" 65 | { 66 | "userId": "foo-123", 67 | "pictureSize": "bar", 68 | "pictureUrl": "http://exmple.com" 69 | } 70 | """) 71 | 72 | filter(userJson) must be(expectedJson) 73 | } 74 | 75 | "write and consequently read, which must produce the original value" in { 76 | val originalUser = UserWithPicture("foo-123", Medium, "http://exmple.com") 77 | val newUser = getFromJSON[UserWithPicture](compact(render(toJValue(originalUser)))) 78 | 79 | newUser must be(originalUser) 80 | } 81 | 82 | "read and write sealed trait with only one subtype" in { 83 | val json = """ 84 | { 85 | "userId": "foo-123", 86 | "pictureSize": "Medium", 87 | "pictureUrl": "http://example.com", 88 | "access": { 89 | "type": "Authorized", 90 | "project": "internal" 91 | } 92 | } 93 | """ 94 | val user = getFromJSON[UserWithPicture](json) 95 | 96 | user must be( 97 | UserWithPicture( 98 | "foo-123", 99 | Medium, 100 | "http://example.com", 101 | Some(Access.Authorized("internal")))) 102 | 103 | val newJson = toJValue[UserWithPicture](user) 104 | Valid(newJson) must be(parseJSON(json)) 105 | 106 | val Valid(newUser) = fromJValue[UserWithPicture](newJson) 107 | newUser must be(user) 108 | } 109 | } 110 | 111 | private def filter(jvalue: JValue): JValue = 112 | jvalue.removeField { 113 | case (_, JNothing) => true 114 | case _ => false 115 | } 116 | } 117 | 118 | sealed abstract class PictureSize(val weight: Int, val height: Int) 119 | 120 | case object Small extends PictureSize(100, 100) 121 | case object Medium extends PictureSize(500, 450) 122 | case object Big extends PictureSize(1024, 2048) 123 | 124 | @JSONTypeHint("bar") 125 | case object Custom extends PictureSize(1, 2) 126 | 127 | object PictureSize { 128 | implicit val json: JSON[PictureSize] = deriveSingletonJSON[PictureSize] 129 | } 130 | 131 | sealed trait Access 132 | object Access { 133 | // only one sub-type 134 | case class Authorized(project: String) extends Access 135 | 136 | implicit val json: JSON[Access] = deriveJSON 137 | } 138 | 139 | case class UserWithPicture( 140 | userId: String, 141 | pictureSize: PictureSize, 142 | pictureUrl: String, 143 | access: Option[Access] = None) 144 | 145 | object UserWithPicture { 146 | implicit val json: JSON[UserWithPicture] = deriveJSON[UserWithPicture] 147 | } 148 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/ForProductNSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import java.util.UUID 4 | 5 | import io.sphere.json.ToJSONProduct._ 6 | import org.json4s._ 7 | import org.scalatest.matchers.must.Matchers 8 | import org.scalatest.wordspec.AnyWordSpec 9 | 10 | case class User(id: UUID, firstName: String, age: Int) 11 | 12 | class ForProductNSpec extends AnyWordSpec with Matchers { 13 | 14 | "forProductN helper methods" must { 15 | "serialize" in { 16 | implicit val encodeUser: ToJSON[User] = forProduct3(u => 17 | ( 18 | "id" -> u.id, 19 | "first_name" -> u.firstName, 20 | "age" -> u.age 21 | )) 22 | 23 | val id = UUID.randomUUID() 24 | val json = toJValue(User(id, "bidule", 109)) 25 | json must be( 26 | JObject( 27 | List( 28 | "id" -> JString(id.toString), 29 | "first_name" -> JString("bidule"), 30 | "age" -> JLong(109) 31 | ))) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/JSONEmbeddedSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import io.sphere.json.generic._ 4 | import org.scalatest.OptionValues 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | object JSONEmbeddedSpec { 9 | 10 | case class Embedded(value1: String, value2: Int) 11 | 12 | object Embedded { 13 | implicit val json: JSON[Embedded] = jsonProduct(apply _) 14 | } 15 | 16 | case class Test1(name: String, @JSONEmbedded embedded: Embedded) 17 | 18 | object Test1 { 19 | implicit val json: JSON[Test1] = jsonProduct(apply _) 20 | } 21 | 22 | case class Test2(name: String, @JSONEmbedded embedded: Option[Embedded] = None) 23 | 24 | object Test2 { 25 | implicit val json: JSON[Test2] = jsonProduct(apply _) 26 | } 27 | 28 | case class SubTest4(@JSONEmbedded embedded: Embedded) 29 | object SubTest4 { 30 | implicit val json: JSON[SubTest4] = jsonProduct(apply _) 31 | } 32 | 33 | case class Test4(subField: Option[SubTest4] = None) 34 | object Test4 { 35 | implicit val json: JSON[Test4] = jsonProduct(apply _) 36 | } 37 | } 38 | 39 | class JSONEmbeddedSpec extends AnyWordSpec with Matchers with OptionValues { 40 | import JSONEmbeddedSpec._ 41 | 42 | "JSONEmbedded" should { 43 | "flatten the json in one object" in { 44 | val json = 45 | """{ 46 | | "name": "ze name", 47 | | "value1": "ze value1", 48 | | "value2": 45 49 | |} 50 | """.stripMargin 51 | val test1 = getFromJSON[Test1](json) 52 | test1.name mustEqual "ze name" 53 | test1.embedded.value1 mustEqual "ze value1" 54 | test1.embedded.value2 mustEqual 45 55 | 56 | val result = toJSON(test1) 57 | parseJSON(result) mustEqual parseJSON(json) 58 | } 59 | 60 | "validate that the json contains all needed fields" in { 61 | val json = 62 | """{ 63 | | "name": "ze name", 64 | | "value1": "ze value1" 65 | |} 66 | """.stripMargin 67 | fromJSON[Test1](json).isInvalid must be(true) 68 | fromJSON[Test1]("""{"name": "a"}""").isInvalid must be(true) 69 | } 70 | 71 | "support optional embedded attribute" in { 72 | val json = 73 | """{ 74 | | "name": "ze name", 75 | | "value1": "ze value1", 76 | | "value2": 45 77 | |} 78 | """.stripMargin 79 | val test2 = getFromJSON[Test2](json) 80 | test2.name mustEqual "ze name" 81 | test2.embedded.value.value1 mustEqual "ze value1" 82 | test2.embedded.value.value2 mustEqual 45 83 | 84 | val result = toJSON(test2) 85 | parseJSON(result) mustEqual parseJSON(json) 86 | } 87 | 88 | "ignore unknown fields" in { 89 | val json = 90 | """{ 91 | | "name": "ze name", 92 | | "value1": "ze value1", 93 | | "value2": 45, 94 | | "value3": true 95 | |} 96 | """.stripMargin 97 | val test2 = getFromJSON[Test2](json) 98 | test2.name mustEqual "ze name" 99 | test2.embedded.value.value1 mustEqual "ze value1" 100 | test2.embedded.value.value2 mustEqual 45 101 | } 102 | 103 | "check for sub-fields" in { 104 | val json = 105 | """ 106 | { 107 | "subField": { 108 | "value1": "ze value1", 109 | "value2": 45 110 | } 111 | } 112 | """ 113 | val test4 = getFromJSON[Test4](json) 114 | test4.subField.value.embedded.value1 mustEqual "ze value1" 115 | test4.subField.value.embedded.value2 mustEqual 45 116 | } 117 | 118 | "support the absence of optional embedded attribute" in { 119 | val json = """{ "name": "ze name" }""" 120 | val test2 = getFromJSON[Test2](json) 121 | test2.name mustEqual "ze name" 122 | test2.embedded mustEqual None 123 | } 124 | 125 | "validate the absence of some embedded attributes" in { 126 | val json = """{ "name": "ze name", "value1": "ze value1" }""" 127 | fromJSON[Test2](json).isInvalid must be(true) 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/NullHandlingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import io.sphere.json.generic._ 4 | import org.json4s.JsonAST.{JNothing, JObject} 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | class NullHandlingSpec extends AnyWordSpec with Matchers { 9 | "JSON deserialization" must { 10 | "should accept undefined fields and use default values for them" in { 11 | val jeans = getFromJSON[Jeans]("{}") 12 | 13 | jeans must be(Jeans(None, None, Set.empty, "secret")) 14 | } 15 | 16 | "should accept null values and use default values for them" in { 17 | val jeans = getFromJSON[Jeans](""" 18 | { 19 | "leftPocket": null, 20 | "rightPocket": null, 21 | "backPocket": null, 22 | "hiddenPocket": null 23 | } 24 | """) 25 | 26 | jeans must be(Jeans(None, None, Set.empty, "secret")) 27 | } 28 | 29 | "should accept JNothing values and use default values for them" in { 30 | val jeans = getFromJValue[Jeans]( 31 | JObject( 32 | "leftPocket" -> JNothing, 33 | "rightPocket" -> JNothing, 34 | "backPocket" -> JNothing, 35 | "hiddenPocket" -> JNothing)) 36 | 37 | jeans must be(Jeans(None, None, Set.empty, "secret")) 38 | } 39 | 40 | "should accept not-null values and use them" in { 41 | val jeans = getFromJSON[Jeans](""" 42 | { 43 | "leftPocket": "Axe", 44 | "rightPocket": "Magic powder", 45 | "backPocket": ["Magic wand", "Rusty sword"], 46 | "hiddenPocket": "The potion of healing" 47 | } 48 | """) 49 | 50 | jeans must be( 51 | Jeans( 52 | Some("Axe"), 53 | Some("Magic powder"), 54 | Set("Magic wand", "Rusty sword"), 55 | "The potion of healing")) 56 | } 57 | } 58 | } 59 | 60 | case class Jeans( 61 | leftPocket: Option[String] = None, 62 | rightPocket: Option[String], 63 | backPocket: Set[String] = Set.empty, 64 | hiddenPocket: String = "secret") 65 | 66 | object Jeans { 67 | implicit val json: JSON[Jeans] = deriveJSON[Jeans] 68 | } 69 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/OptionReaderSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import io.sphere.json.generic._ 4 | import org.json4s.{JArray, JLong, JNothing, JObject, JString} 5 | import org.scalatest.OptionValues 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | object OptionReaderSpec { 10 | 11 | case class SimpleClass(value1: String, value2: Int) 12 | 13 | object SimpleClass { 14 | implicit val json: JSON[SimpleClass] = jsonProduct(apply _) 15 | } 16 | 17 | case class ComplexClass(name: String, simpleClass: Option[SimpleClass]) 18 | 19 | object ComplexClass { 20 | implicit val json: JSON[ComplexClass] = jsonProduct(apply _) 21 | } 22 | 23 | case class MapClass(id: Long, map: Option[Map[String, String]]) 24 | object MapClass { 25 | implicit val json: JSON[MapClass] = jsonProduct(apply _) 26 | } 27 | 28 | case class ListClass(id: Long, list: Option[List[String]]) 29 | object ListClass { 30 | implicit val json: JSON[ListClass] = jsonProduct(apply _) 31 | } 32 | } 33 | 34 | class OptionReaderSpec extends AnyWordSpec with Matchers with OptionValues { 35 | import OptionReaderSpec._ 36 | 37 | "OptionReader" should { 38 | "handle presence of all fields" in { 39 | val json = 40 | """{ 41 | | "value1": "a", 42 | | "value2": 45 43 | |} 44 | """.stripMargin 45 | val result = getFromJSON[Option[SimpleClass]](json) 46 | result.value.value1 mustEqual "a" 47 | result.value.value2 mustEqual 45 48 | } 49 | 50 | "handle presence of all fields mixed with ignored fields" in { 51 | val json = 52 | """{ 53 | | "value1": "a", 54 | | "value2": 45, 55 | | "value3": "b" 56 | |} 57 | """.stripMargin 58 | val result = getFromJSON[Option[SimpleClass]](json) 59 | result.value.value1 mustEqual "a" 60 | result.value.value2 mustEqual 45 61 | } 62 | 63 | "handle presence of not all the fields" in { 64 | val json = """{ "value1": "a" }""" 65 | fromJSON[Option[SimpleClass]](json).isInvalid must be(true) 66 | } 67 | 68 | "handle absence of all fields" in { 69 | val json = "{}" 70 | val result = getFromJSON[Option[SimpleClass]](json) 71 | result mustEqual None 72 | } 73 | 74 | "handle optional map" in { 75 | getFromJValue[MapClass](JObject("id" -> JLong(1L))) mustEqual MapClass(1L, None) 76 | 77 | getFromJValue[MapClass](JObject("id" -> JLong(1L), "map" -> JObject())) mustEqual 78 | MapClass(1L, Some(Map.empty)) 79 | 80 | getFromJValue[MapClass]( 81 | JObject("id" -> JLong(1L), "map" -> JObject("a" -> JString("b")))) mustEqual 82 | MapClass(1L, Some(Map("a" -> "b"))) 83 | 84 | toJValue[MapClass](MapClass(1L, None)) mustEqual 85 | JObject("id" -> JLong(1L), "map" -> JNothing) 86 | toJValue[MapClass](MapClass(1L, Some(Map()))) mustEqual 87 | JObject("id" -> JLong(1L), "map" -> JObject()) 88 | toJValue[MapClass](MapClass(1L, Some(Map("a" -> "b")))) mustEqual 89 | JObject("id" -> JLong(1L), "map" -> JObject("a" -> JString("b"))) 90 | } 91 | 92 | "handle optional list" in { 93 | getFromJValue[ListClass]( 94 | JObject("id" -> JLong(1L), "list" -> JArray(List(JString("hi"))))) mustEqual 95 | ListClass(1L, Some(List("hi"))) 96 | getFromJValue[ListClass](JObject("id" -> JLong(1L), "list" -> JArray(List.empty))) mustEqual 97 | ListClass(1L, Some(List())) 98 | getFromJValue[ListClass](JObject("id" -> JLong(1L))) mustEqual 99 | ListClass(1L, None) 100 | 101 | toJValue(ListClass(1L, Some(List("hi")))) mustEqual JObject( 102 | "id" -> JLong(1L), 103 | "list" -> JArray(List(JString("hi")))) 104 | toJValue(ListClass(1L, Some(List.empty))) mustEqual JObject( 105 | "id" -> JLong(1L), 106 | "list" -> JArray(List.empty)) 107 | toJValue(ListClass(1L, None)) mustEqual JObject("id" -> JLong(1L), "list" -> JNothing) 108 | } 109 | 110 | "handle absence of all fields mixed with ignored fields" in { 111 | val json = """{ "value3": "a" }""" 112 | val result = getFromJSON[Option[SimpleClass]](json) 113 | result mustEqual None 114 | } 115 | 116 | "consider all fields if the data type does not impose any restriction" in { 117 | val json = 118 | """{ 119 | | "key1": "value1", 120 | | "key2": "value2" 121 | |} 122 | """.stripMargin 123 | val expected = Map("key1" -> "value1", "key2" -> "value2") 124 | val result = getFromJSON[Map[String, String]](json) 125 | result mustEqual expected 126 | 127 | val maybeResult = getFromJSON[Option[Map[String, String]]](json) 128 | maybeResult.value mustEqual expected 129 | } 130 | 131 | "parse optional element" in { 132 | val json = 133 | """{ 134 | | "name": "ze name", 135 | | "simpleClass": { 136 | | "value1": "value1", 137 | | "value2": 42 138 | | } 139 | |} 140 | """.stripMargin 141 | val result = getFromJSON[ComplexClass](json) 142 | result.simpleClass.value.value1 mustEqual "value1" 143 | result.simpleClass.value.value2 mustEqual 42 144 | 145 | parseJSON(toJSON(result)) mustEqual parseJSON(json) 146 | } 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/TypesSwitchSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json 2 | 3 | import io.sphere.json.generic.{TypeSelectorContainer, deriveJSON, jsonTypeSwitch} 4 | import org.json4s._ 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | class TypesSwitchSpec extends AnyWordSpec with Matchers { 9 | import TypesSwitchSpec._ 10 | 11 | "jsonTypeSwitch" must { 12 | "combine different sum types tree" in { 13 | val m: Seq[Message] = List( 14 | TypeA.ClassA1(23), 15 | TypeA.ClassA2("world"), 16 | TypeB.ClassB1(valid = false), 17 | TypeB.ClassB2(Seq("a23", "c62"))) 18 | 19 | val jsons = m.map(Message.json.write) 20 | jsons must be( 21 | List( 22 | JObject("number" -> JLong(23), "type" -> JString("ClassA1")), 23 | JObject("name" -> JString("world"), "type" -> JString("ClassA2")), 24 | JObject("valid" -> JBool(false), "type" -> JString("ClassB1")), 25 | JObject( 26 | "references" -> JArray(List(JString("a23"), JString("c62"))), 27 | "type" -> JString("ClassB2")) 28 | )) 29 | 30 | val messages = jsons.map(Message.json.read).map(_.toOption.get) 31 | messages must be(m) 32 | } 33 | } 34 | 35 | "TypeSelectorContainer" must { 36 | "have information about type value discriminators" in { 37 | val selectors = Message.json.typeSelectors 38 | selectors.map(_.typeValue) must contain.allOf( 39 | "ClassA1", 40 | "ClassA2", 41 | "TypeA", 42 | "ClassB1", 43 | "ClassB2", 44 | "TypeB") 45 | 46 | // I don't think it's useful to allow different type fields. How is it possible to deserialize one json 47 | // if different type fields are used? 48 | selectors.map(_.typeField) must be(List("type", "type", "type", "type", "type", "type")) 49 | 50 | selectors.map(_.clazz.getName) must contain.allOf( 51 | "io.sphere.json.TypesSwitchSpec$TypeA$ClassA1", 52 | "io.sphere.json.TypesSwitchSpec$TypeA$ClassA2", 53 | "io.sphere.json.TypesSwitchSpec$TypeA", 54 | "io.sphere.json.TypesSwitchSpec$TypeB$ClassB1", 55 | "io.sphere.json.TypesSwitchSpec$TypeB$ClassB2", 56 | "io.sphere.json.TypesSwitchSpec$TypeB" 57 | ) 58 | } 59 | } 60 | 61 | } 62 | 63 | object TypesSwitchSpec { 64 | 65 | trait Message 66 | object Message { 67 | // this can be dangerous is the same class name is used in both sum types 68 | // ex if we define TypeA.Class1 && TypeB.Class1 69 | // as both will use the same type value discriminator 70 | implicit val json: JSON[Message] with TypeSelectorContainer = 71 | jsonTypeSwitch[Message, TypeA, TypeB](Nil) 72 | } 73 | 74 | sealed trait TypeA extends Message 75 | object TypeA { 76 | case class ClassA1(number: Int) extends TypeA 77 | case class ClassA2(name: String) extends TypeA 78 | implicit val json: JSON[TypeA] = deriveJSON[TypeA] 79 | } 80 | 81 | sealed trait TypeB extends Message 82 | object TypeB { 83 | case class ClassB1(valid: Boolean) extends TypeB 84 | case class ClassB2(references: Seq[String]) extends TypeB 85 | implicit val json: JSON[TypeB] = deriveJSON[TypeB] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.json._ 4 | import io.sphere.json.generic._ 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | class DefaultValuesSpec extends AnyWordSpec with Matchers { 9 | import DefaultValuesSpec._ 10 | 11 | "deriving JSON" must { 12 | "handle default values" in { 13 | val json = "{}" 14 | val test = getFromJSON[Test](json) 15 | test.value1 must be("hello") 16 | test.value2 must be(None) 17 | test.value3 must be(None) 18 | test.value4 must be(Some("hi")) 19 | } 20 | } 21 | } 22 | 23 | object DefaultValuesSpec { 24 | case class Test( 25 | value1: String = "hello", 26 | value2: Option[String], 27 | value3: Option[String] = None, 28 | value4: Option[String] = Some("hi") 29 | ) 30 | object Test { 31 | implicit val mongo: JSON[Test] = jsonProduct(apply _) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.json._ 4 | import io.sphere.json.generic._ 5 | import org.json4s.DefaultReaders._ 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class JSONKeySpec extends AnyWordSpec with Matchers { 10 | import JSONKeySpec._ 11 | 12 | "deriving JSON" must { 13 | "rename fields annotated with @JSONKey" in { 14 | val test = 15 | Test(value1 = "value1", value2 = "value2", subTest = SubTest(value2 = "other_value2")) 16 | 17 | val json = toJValue(test) 18 | (json \ "value1").as[Option[String]] must be(Some("value1")) 19 | (json \ "value2").as[Option[String]] must be(None) 20 | (json \ "new_value_2").as[Option[String]] must be(Some("value2")) 21 | (json \ "new_sub_value_2").as[Option[String]] must be(Some("other_value2")) 22 | 23 | val newTest = getFromJValue[Test](json) 24 | newTest must be(test) 25 | } 26 | } 27 | } 28 | 29 | object JSONKeySpec { 30 | case class SubTest( 31 | @JSONKey("new_sub_value_2") value2: String 32 | ) 33 | object SubTest { 34 | implicit val mongo: JSON[SubTest] = jsonProduct(apply _) 35 | } 36 | 37 | case class Test( 38 | value1: String, 39 | @JSONKey("new_value_2") value2: String, 40 | @JSONEmbedded subTest: SubTest 41 | ) 42 | object Test { 43 | implicit val mongo: JSON[Test] = jsonProduct(apply _) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /json/json-derivation/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.json.generic 2 | 3 | import cats.data.Validated.Valid 4 | import io.sphere.json._ 5 | import org.json4s._ 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers { 10 | import JsonTypeHintFieldSpec._ 11 | 12 | "JSONTypeHintField" must { 13 | "allow to set another field to distinguish between types (toMongo)" in { 14 | val user = UserWithPicture("foo-123", Medium, "http://example.com") 15 | val expected = JObject( 16 | List( 17 | "userId" -> JString("foo-123"), 18 | "pictureSize" -> JObject(List("pictureType" -> JString("Medium"))), 19 | "pictureUrl" -> JString("http://example.com"))) 20 | 21 | val json = toJValue[UserWithPicture](user) 22 | json must be(expected) 23 | } 24 | 25 | "allow to set another field to distinguish between types (fromMongo)" in { 26 | val json = 27 | """ 28 | { 29 | "userId": "foo-123", 30 | "pictureSize": { "pictureType": "Medium" }, 31 | "pictureUrl": "http://example.com" 32 | } 33 | """ 34 | 35 | val Valid(user) = fromJSON[UserWithPicture](json) 36 | 37 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 38 | } 39 | } 40 | 41 | } 42 | 43 | object JsonTypeHintFieldSpec { 44 | 45 | @JSONTypeHintField(value = "pictureType") 46 | sealed trait PictureSize 47 | @JSONTypeHintField(value = "pictureType") 48 | case object Small extends PictureSize 49 | @JSONTypeHintField(value = "pictureType") 50 | case object Medium extends PictureSize 51 | @JSONTypeHintField(value = "pictureType") 52 | case object Big extends PictureSize 53 | 54 | object PictureSize { 55 | implicit val json: JSON[PictureSize] = deriveJSON[PictureSize] 56 | } 57 | 58 | case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) 59 | 60 | object UserWithPicture { 61 | implicit val json: JSON[UserWithPicture] = jsonProduct(apply _) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /mongo/README.md: -------------------------------------------------------------------------------- 1 | # sphere-mongo 2 | 3 | Typeclasses & derived instances on top of mongo driver. 4 | 5 | ## Repository 6 | 7 | [![latest release](https://img.shields.io/maven-central/v/com.commercetools/sphere-mongo_2.13.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.commercetools%20AND%20a:sphere-mongo*) -------------------------------------------------------------------------------- /mongo/mongo-core/dependencies.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies ++= Seq( 2 | "org.mongodb" % "mongodb-driver-core" % "5.5.0" 3 | ) 4 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/main/scala/io/sphere/mongo/catsinstances/package.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | 3 | import _root_.cats.Invariant 4 | import io.sphere.mongo.format.MongoFormat 5 | 6 | /** Cats instances for [[MongoFormat]] 7 | */ 8 | package object catsinstances extends MongoFormatInstances 9 | 10 | trait MongoFormatInstances { 11 | implicit val catsInvariantForMongoFormat: Invariant[MongoFormat] = 12 | new MongoFormatInvariant 13 | } 14 | 15 | class MongoFormatInvariant extends Invariant[MongoFormat] { 16 | override def imap[A, B](fa: MongoFormat[A])(f: A => B)(g: B => A): MongoFormat[B] = 17 | new MongoFormat[B] { 18 | override def toMongoValue(b: B): Any = fa.toMongoValue(g(b)) 19 | override def fromMongoValue(any: Any): B = f(fa.fromMongoValue(any)) 20 | override val fields: Set[String] = fa.fields 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/main/scala/io/sphere/mongo/format/MongoFormat.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.format 2 | 3 | import io.sphere.mongo.MongoFormatInstances 4 | import org.bson.BSONObject 5 | 6 | import scala.annotation.implicitNotFound 7 | 8 | /** Typeclass for types with a MongoDB (Java driver) format. */ 9 | @implicitNotFound("Could not find an instance of MongoFormat for ${A}") 10 | trait MongoFormat[@specialized A] extends Serializable { 11 | def toMongoValue(a: A): Any 12 | def fromMongoValue(any: Any): A 13 | def default: Option[A] = None 14 | 15 | /** needed JSON fields - ignored if empty */ 16 | val fields: Set[String] = MongoFormat.emptyFieldsSet 17 | } 18 | 19 | object MongoFormat extends MongoFormatInstances { 20 | 21 | private[MongoFormat] val emptyFieldsSet: Set[String] = Set.empty 22 | 23 | @inline def apply[A](implicit instance: MongoFormat[A]): MongoFormat[A] = instance 24 | 25 | /** Puts `value` under `name` into the given `BSONObject`, thereby requiring and applying a 26 | * MongoFormat for the value type `A`. If ''value'' is ''None'' it is (intentionally) ignored. 27 | * 28 | * @param dbo 29 | * The BSONObject into which to put ''value'' under ''name''. 30 | * @tparam A 31 | * The value type, for which there must exist a MongoFormat[A] instance. 32 | * @return 33 | * The passed dbo with the new key-value pair added. 34 | */ 35 | def put[A: MongoFormat](dbo: BSONObject)(name: String, value: A): BSONObject = { 36 | // Ignore None's, we don't ever want to store or use nulls. 37 | if (value != None) dbo.put(name, MongoFormat[A].toMongoValue(value)) 38 | dbo 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/main/scala/io/sphere/mongo/format/package.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | 3 | package object format { 4 | 5 | def toMongo[A: MongoFormat](a: A): AnyRef = MongoFormat[A].toMongoValue(a).asInstanceOf[AnyRef] 6 | def fromMongo[A: MongoFormat](any: Any): A = MongoFormat[A].fromMongoValue(any) 7 | 8 | } 9 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/test/scala/io/sphere/mongo/MongoUtils.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | import com.mongodb.BasicDBObject 3 | 4 | object MongoUtils { 5 | 6 | def dbObj(pairs: (String, Any)*) = 7 | pairs.foldLeft(new BasicDBObject) { case (obj, (key, value)) => obj.append(key, value) } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/test/scala/io/sphere/mongo/catsinstances/MongoFormatCatsInstancesTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.catsinstances 2 | 3 | import cats.syntax.invariant._ 4 | import io.sphere.mongo.format.DefaultMongoFormats._ 5 | import io.sphere.mongo.format._ 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class MongoFormatCatsInstancesTest extends AnyWordSpec with Matchers { 10 | import MongoFormatCatsInstancesTest._ 11 | 12 | "Invariant[MongoFormat]" must { 13 | "allow imaping a default format" in { 14 | val myId = MyId("test") 15 | val dbo = toMongo(myId) 16 | dbo.asInstanceOf[String] must be("test") 17 | val myNewId = fromMongo[MyId](dbo) 18 | myNewId must be(myId) 19 | } 20 | } 21 | } 22 | 23 | object MongoFormatCatsInstancesTest { 24 | case class MyId(id: String) extends AnyVal 25 | object MyId { 26 | implicit val mongo: MongoFormat[MyId] = MongoFormat[String].imap(MyId.apply)(_.id) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.format 2 | 3 | import java.util.Currency 4 | 5 | import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} 6 | import DefaultMongoFormats._ 7 | import io.sphere.mongo.MongoUtils._ 8 | import org.bson.BSONObject 9 | import org.scalatest.wordspec.AnyWordSpec 10 | import org.scalatest.matchers.should.Matchers 11 | 12 | import scala.collection.JavaConverters._ 13 | 14 | class BaseMoneyMongoFormatTest extends AnyWordSpec with Matchers { 15 | 16 | "MongoFormat[BaseMoney]" should { 17 | "be symmetric" in { 18 | val money = Money.EUR(34.56) 19 | val f = MongoFormat[Money] 20 | val dbo = f.toMongoValue(money) 21 | val readMoney = f.fromMongoValue(dbo) 22 | 23 | money should be(readMoney) 24 | } 25 | 26 | "decode with type info" in { 27 | val dbo = dbObj( 28 | "type" -> "centPrecision", 29 | "currencyCode" -> "USD", 30 | "centAmount" -> 3298 31 | ) 32 | 33 | MongoFormat[BaseMoney].fromMongoValue(dbo) should be(Money.USD(BigDecimal("32.98"))) 34 | } 35 | 36 | "decode without type info" in { 37 | val dbo = dbObj( 38 | "currencyCode" -> "USD", 39 | "centAmount" -> 3298 40 | ) 41 | 42 | MongoFormat[BaseMoney].fromMongoValue(dbo) should be(Money.USD(BigDecimal("32.98"))) 43 | } 44 | } 45 | 46 | "MongoFormat[HighPrecisionMoney]" should { 47 | "be symmetric" in { 48 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 49 | 50 | val money = HighPrecisionMoney.fromDecimalAmount(34.123456, 6, Currency.getInstance("EUR")) 51 | val dbo = MongoFormat[HighPrecisionMoney].toMongoValue(money) 52 | 53 | val decodedMoney = MongoFormat[HighPrecisionMoney].fromMongoValue(dbo) 54 | val decodedBaseMoney = MongoFormat[BaseMoney].fromMongoValue(dbo) 55 | 56 | decodedMoney should equal(money) 57 | decodedBaseMoney should equal(money) 58 | } 59 | 60 | "decode with type info" in { 61 | val dbo = dbObj( 62 | "type" -> "highPrecision", 63 | "currencyCode" -> "USD", 64 | "preciseAmount" -> 42, 65 | "fractionDigits" -> 4 66 | ) 67 | 68 | MongoFormat[BaseMoney].fromMongoValue(dbo) should be( 69 | HighPrecisionMoney.USD(BigDecimal("0.0042"), Some(4))) 70 | } 71 | 72 | "decode with centAmount" in { 73 | val dbo = dbObj( 74 | "type" -> "highPrecision", 75 | "currencyCode" -> "USD", 76 | "preciseAmount" -> 42, 77 | "centAmount" -> 1, 78 | "fractionDigits" -> 4 79 | ) 80 | 81 | val parsed = MongoFormat[BaseMoney].fromMongoValue(dbo) 82 | MongoFormat[BaseMoney].toMongoValue(parsed).asInstanceOf[BSONObject].toMap.asScala should be( 83 | dbo.toMap.asScala) 84 | } 85 | 86 | "validate data when decoded from JSON" in { 87 | val dbo = dbObj( 88 | "type" -> "highPrecision", 89 | "currencyCode" -> "USD", 90 | "preciseAmount" -> 42, 91 | "fractionDigits" -> 1 92 | ) 93 | 94 | an[Exception] shouldBe thrownBy(MongoFormat[BaseMoney].fromMongoValue(dbo)) 95 | } 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /mongo/mongo-core/src/test/scala/io/sphere/mongo/format/DefaultMongoFormatsTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.format 2 | 3 | import java.util.Locale 4 | import com.mongodb.DBObject 5 | import io.sphere.mongo.MongoUtils 6 | import io.sphere.mongo.format.DefaultMongoFormats._ 7 | import io.sphere.util.LangTag 8 | import org.bson.BasicBSONObject 9 | import org.bson.types.BasicBSONList 10 | import org.scalacheck.Gen 11 | import org.scalatest.matchers.must.Matchers 12 | import org.scalatest.wordspec.AnyWordSpec 13 | import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks 14 | 15 | import scala.collection.JavaConverters._ 16 | 17 | object DefaultMongoFormatsTest { 18 | case class User(name: String) 19 | object User { 20 | implicit val mongo: MongoFormat[User] = new MongoFormat[User] { 21 | override def toMongoValue(a: User): Any = MongoUtils.dbObj("name" -> a.name) 22 | override def fromMongoValue(any: Any): User = any match { 23 | case dbo: DBObject => 24 | User(dbo.get("name").asInstanceOf[String]) 25 | case _ => throw new Exception("expected DBObject") 26 | } 27 | } 28 | } 29 | } 30 | 31 | class DefaultMongoFormatsTest 32 | extends AnyWordSpec 33 | with Matchers 34 | with ScalaCheckDrivenPropertyChecks { 35 | import DefaultMongoFormatsTest._ 36 | 37 | "DefaultMongoFormats" must { 38 | "support List[String]" in { 39 | val format = listFormat[String] 40 | val list = Gen.listOf(Gen.alphaNumStr) 41 | 42 | forAll(list) { l => 43 | val dbo = format.toMongoValue(l) 44 | dbo.asInstanceOf[BasicBSONList].asScala.toList must be(l) 45 | val resultList = format.fromMongoValue(dbo) 46 | resultList must be(l) 47 | } 48 | } 49 | 50 | "support List[A: MongoFormat]" in { 51 | val format = listFormat[User] 52 | val list = Gen.listOf(Gen.alphaNumStr.map(User.apply)) 53 | 54 | check(list, format) 55 | } 56 | 57 | "support Vector[String]" in { 58 | val format = vecFormat[String] 59 | val vector = Gen.listOf(Gen.alphaNumStr).map(_.toVector) 60 | 61 | forAll(vector) { v => 62 | val dbo = format.toMongoValue(v) 63 | dbo.asInstanceOf[BasicBSONList].asScala.toVector must be(v) 64 | val resultVector = format.fromMongoValue(dbo) 65 | resultVector must be(v) 66 | } 67 | } 68 | 69 | "support Vector[A: MongoFormat]" in { 70 | val format = vecFormat[User] 71 | val vector = Gen.listOf(Gen.alphaNumStr.map(User.apply)).map(_.toVector) 72 | 73 | check(vector, format) 74 | } 75 | 76 | "support Set[String]" in { 77 | val format = setFormat[String] 78 | val set = Gen.listOf(Gen.alphaNumStr).map(_.toSet) 79 | 80 | forAll(set) { s => 81 | val dbo = format.toMongoValue(s) 82 | dbo.asInstanceOf[BasicBSONList].asScala.toSet must be(s) 83 | val resultSet = format.fromMongoValue(dbo) 84 | resultSet must be(s) 85 | } 86 | } 87 | 88 | "support Set[A: MongoFormat]" in { 89 | val format = setFormat[User] 90 | val set = Gen.listOf(Gen.alphaNumStr.map(User.apply)).map(_.toSet) 91 | 92 | check(set, format) 93 | } 94 | 95 | "support Map[String, String]" in { 96 | val format = mapFormat[String] 97 | val map = Gen 98 | .listOf { 99 | for { 100 | key <- Gen.alphaNumStr 101 | value <- Gen.alphaNumStr 102 | } yield (key, value) 103 | } 104 | .map(_.toMap) 105 | 106 | forAll(map) { m => 107 | val dbo = format.toMongoValue(m) 108 | dbo.asInstanceOf[BasicBSONObject].asScala must be(m) 109 | val resultMap = format.fromMongoValue(dbo) 110 | resultMap must be(m) 111 | } 112 | } 113 | 114 | "support Map[String, A: MongoFormat]" in { 115 | val format = mapFormat[User] 116 | val map = Gen 117 | .listOf { 118 | for { 119 | key <- Gen.alphaNumStr 120 | value <- Gen.alphaNumStr.map(User.apply) 121 | } yield (key, value) 122 | } 123 | .map(_.toMap) 124 | 125 | check(map, format) 126 | } 127 | 128 | "support Java Locale" in { 129 | Locale.getAvailableLocales.filter(_.toLanguageTag != LangTag.UNDEFINED).foreach { 130 | (l: Locale) => 131 | localeFormat.fromMongoValue(localeFormat.toMongoValue(l)).toLanguageTag must be( 132 | l.toLanguageTag) 133 | } 134 | } 135 | 136 | "support UUID" in { 137 | val format = uuidFormat 138 | val uuids = Gen.uuid 139 | 140 | check(uuids, format) 141 | } 142 | } 143 | 144 | private def check[A](gen: Gen[A], format: MongoFormat[A]) = 145 | forAll(gen) { value => 146 | val dbo = format.toMongoValue(value) 147 | val result = format.fromMongoValue(dbo) 148 | result must be(value) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/dependencies.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies ++= Seq( 2 | "com.propensive" %% "magnolia" % "0.17.0" 3 | ) 4 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoEmbedded.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | class MongoEmbedded extends StaticAnnotation 6 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoIgnore.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | class MongoIgnore extends StaticAnnotation 6 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoKey.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | case class MongoKey(value: String) extends StaticAnnotation 6 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHint.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | case class MongoTypeHint(value: String) extends StaticAnnotation 6 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHintField.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | case class MongoTypeHintField(value: String = MongoTypeHintField.defaultValue) 6 | extends StaticAnnotation 7 | 8 | object MongoTypeHintField { 9 | final val defaultValue: String = "type" 10 | } 11 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/MongoUtils.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | import com.mongodb.BasicDBObject 3 | 4 | object MongoUtils { 5 | 6 | def dbObj(pairs: (String, Any)*) = 7 | pairs.foldLeft(new BasicDBObject) { case (obj, (key, value)) => obj.append(key, value) } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/SerializationTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | 3 | import com.mongodb.{BasicDBObject, DBObject} 4 | import org.scalatest.matchers.must.Matchers 5 | import io.sphere.mongo.format.MongoFormat 6 | import io.sphere.mongo.format.DefaultMongoFormats._ 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | object SerializationTest { 10 | case class Something(a: Option[Int], b: Int = 2) 11 | 12 | object Color extends Enumeration { 13 | val Blue, Red, Yellow = Value 14 | } 15 | } 16 | 17 | class SerializationTest extends AnyWordSpec with Matchers { 18 | import SerializationTest._ 19 | 20 | "mongoProduct" must { 21 | "deserialize mongo object" in { 22 | val dbo = new BasicDBObject() 23 | dbo.put("a", Integer.valueOf(3)) 24 | dbo.put("b", Integer.valueOf(4)) 25 | 26 | val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat 27 | val something = mongoFormat.fromMongoValue(dbo) 28 | something must be(Something(Some(3), 4)) 29 | } 30 | 31 | "generate a format that serializes optional fields with value None as BSON objects without that field" in { 32 | val testFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat[Something] 33 | val serializedObject = testFormat.toMongoValue(Something(None, 1)).asInstanceOf[DBObject] 34 | serializedObject.keySet().contains("b") must be(true) 35 | serializedObject.keySet().contains("a") must be(false) 36 | } 37 | 38 | "generate a format that use default values" in { 39 | val dbo = new BasicDBObject() 40 | dbo.put("a", Integer.valueOf(3)) 41 | 42 | val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat 43 | val something = mongoFormat.fromMongoValue(dbo) 44 | something must be(Something(Some(3), 2)) 45 | } 46 | } 47 | 48 | "mongoEnum" must { 49 | "serialize and deserialize enums" in { 50 | val mongo: MongoFormat[Color.Value] = generic.mongoEnum(Color) 51 | 52 | // mongo java driver knows how to encode/decode Strings 53 | val serializedObject = mongo.toMongoValue(Color.Red).asInstanceOf[String] 54 | serializedObject must be("Red") 55 | 56 | val enumValue = mongo.fromMongoValue(serializedObject) 57 | enumValue must be(Color.Red) 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.format 2 | 3 | import io.sphere.mongo.generic._ 4 | import org.scalatest.OptionValues 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.MongoUtils._ 8 | import DefaultMongoFormats._ 9 | 10 | object OptionMongoFormatSpec { 11 | 12 | case class SimpleClass(value1: String, value2: Int) 13 | 14 | object SimpleClass { 15 | implicit val mongo: MongoFormat[SimpleClass] = deriveMongoFormat 16 | } 17 | 18 | case class ComplexClass(name: String, simpleClass: Option[SimpleClass]) 19 | 20 | object ComplexClass { 21 | implicit val mongo: MongoFormat[ComplexClass] = deriveMongoFormat 22 | } 23 | 24 | } 25 | 26 | class OptionMongoFormatSpec extends AnyWordSpec with Matchers with OptionValues { 27 | import OptionMongoFormatSpec._ 28 | 29 | "MongoFormat[Option[_]]" should { 30 | "handle presence of all fields" in { 31 | val dbo = dbObj( 32 | "value1" -> "a", 33 | "value2" -> 45 34 | ) 35 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 36 | result.value.value1 mustEqual "a" 37 | result.value.value2 mustEqual 45 38 | } 39 | 40 | "handle presence of all fields mixed with ignored fields" in { 41 | val dbo = dbObj( 42 | "value1" -> "a", 43 | "value2" -> 45, 44 | "value3" -> "b" 45 | ) 46 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 47 | result.value.value1 mustEqual "a" 48 | result.value.value2 mustEqual 45 49 | } 50 | 51 | "handle presence of not all the fields" in { 52 | val dbo = dbObj("value1" -> "a") 53 | an[Exception] mustBe thrownBy(MongoFormat[Option[SimpleClass]].fromMongoValue(dbo)) 54 | } 55 | 56 | "handle absence of all fields" in { 57 | val dbo = dbObj() 58 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 59 | result mustEqual None 60 | } 61 | 62 | "handle absence of all fields mixed with ignored fields" in { 63 | val dbo = dbObj("value3" -> "a") 64 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 65 | result mustEqual None 66 | } 67 | 68 | "consider all fields if the data type does not impose any restriction" in { 69 | val dbo = dbObj( 70 | "key1" -> "value1", 71 | "key2" -> "value2" 72 | ) 73 | val expected = Map("key1" -> "value1", "key2" -> "value2") 74 | val result = MongoFormat[Map[String, String]].fromMongoValue(dbo) 75 | result mustEqual expected 76 | 77 | val maybeResult = MongoFormat[Option[Map[String, String]]].fromMongoValue(dbo) 78 | maybeResult.value mustEqual expected 79 | } 80 | 81 | "parse optional element" in { 82 | val dbo = dbObj( 83 | "name" -> "ze name", 84 | "simpleClass" -> dbObj( 85 | "value1" -> "value1", 86 | "value2" -> 42 87 | ) 88 | ) 89 | val result = MongoFormat[ComplexClass].fromMongoValue(dbo) 90 | result.simpleClass.value.value1 mustEqual "value1" 91 | result.simpleClass.value.value2 mustEqual 42 92 | 93 | MongoFormat[ComplexClass].toMongoValue(result) mustEqual dbo 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DefaultValuesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils._ 4 | import io.sphere.mongo.format.DefaultMongoFormats._ 5 | import io.sphere.mongo.format.MongoFormat 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class DefaultValuesSpec extends AnyWordSpec with Matchers { 10 | import DefaultValuesSpec._ 11 | 12 | "deriving MongoFormat" must { 13 | "handle default values" in { 14 | val dbo = dbObj() 15 | val test = MongoFormat[Test].fromMongoValue(dbo) 16 | test.value1 must be("hello") 17 | test.value2 must be(None) 18 | test.value3 must be(None) 19 | test.value4 must be(Some("hi")) 20 | } 21 | } 22 | } 23 | 24 | object DefaultValuesSpec { 25 | case class Test( 26 | value1: String = "hello", 27 | value2: Option[String], 28 | value3: Option[String] = None, 29 | value4: Option[String] = Some("hi") 30 | ) 31 | object Test { 32 | implicit val mongo: MongoFormat[Test] = deriveMongoFormat 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.MongoFormat 4 | import io.sphere.mongo.format._ 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | import io.sphere.mongo.MongoUtils._ 9 | 10 | class DeriveMongoformatSpec extends AnyWordSpec with Matchers { 11 | import DeriveMongoformatSpec._ 12 | 13 | "deriving MongoFormat" must { 14 | "read normal singleton values" in { 15 | val user = fromMongo[UserWithPicture]( 16 | dbObj( 17 | "userId" -> "foo-123", 18 | "pictureSize" -> dbObj("type" -> "Medium"), 19 | "pictureUrl" -> "http://example.com")) 20 | 21 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 22 | } 23 | 24 | "read custom singleton values" in { 25 | val user = fromMongo[UserWithPicture]( 26 | dbObj( 27 | "userId" -> "foo-123", 28 | "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), 29 | "pictureUrl" -> "http://example.com")) 30 | 31 | user must be(UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) 32 | } 33 | 34 | "fail to read if singleton value is unknown" in { 35 | a[Exception] must be thrownBy fromMongo[UserWithPicture]( 36 | dbObj( 37 | "userId" -> "foo-123", 38 | "pictureSize" -> dbObj("type" -> "Unknown"), 39 | "pictureUrl" -> "http://example.com")) 40 | } 41 | 42 | "write normal singleton values" in { 43 | val dbo = toMongo[UserWithPicture](UserWithPicture("foo-123", Medium, "http://example.com")) 44 | dbo must be( 45 | dbObj( 46 | "userId" -> "foo-123", 47 | "pictureSize" -> dbObj("type" -> "Medium"), 48 | "pictureUrl" -> "http://example.com")) 49 | } 50 | 51 | "write custom singleton values" in { 52 | val dbo = 53 | toMongo[UserWithPicture](UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) 54 | dbo must be( 55 | dbObj( 56 | "userId" -> "foo-123", 57 | "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), 58 | "pictureUrl" -> "http://example.com")) 59 | } 60 | 61 | "write and consequently read, which must produce the original value" in { 62 | val originalUser = UserWithPicture("foo-123", Medium, "http://exmple.com") 63 | val newUser = fromMongo[UserWithPicture](toMongo[UserWithPicture](originalUser)) 64 | 65 | newUser must be(originalUser) 66 | } 67 | 68 | "read and write sealed trait with only one subtype" in { 69 | val dbo = dbObj( 70 | "userId" -> "foo-123", 71 | "pictureSize" -> dbObj("type" -> "Medium"), 72 | "pictureUrl" -> "http://example.com", 73 | "access" -> dbObj("type" -> "Authorized", "project" -> "internal") 74 | ) 75 | val user = fromMongo[UserWithPicture](dbo) 76 | 77 | user must be( 78 | UserWithPicture( 79 | "foo-123", 80 | Medium, 81 | "http://example.com", 82 | Some(Access.Authorized("internal")))) 83 | val newDbo = toMongo[UserWithPicture](user) 84 | newDbo must be(dbo) 85 | 86 | val newUser = fromMongo[UserWithPicture](newDbo) 87 | newUser must be(user) 88 | } 89 | } 90 | } 91 | 92 | object DeriveMongoformatSpec { 93 | sealed trait PictureSize 94 | case object Small extends PictureSize 95 | case object Medium extends PictureSize 96 | case object Big extends PictureSize 97 | @MongoTypeHint(value = "bar") 98 | case class Custom(width: Int, height: Int) extends PictureSize 99 | 100 | object PictureSize { 101 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 102 | } 103 | 104 | sealed trait Access 105 | object Access { 106 | // only one sub-type 107 | case class Authorized(project: String) extends Access 108 | 109 | implicit val mongo: MongoFormat[Access] = deriveMongoFormat 110 | } 111 | 112 | case class UserWithPicture( 113 | userId: String, 114 | pictureSize: PictureSize, 115 | pictureUrl: String, 116 | access: Option[Access] = None) 117 | 118 | object UserWithPicture { 119 | implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoEmbeddedSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.MongoFormat 4 | import org.scalatest.OptionValues 5 | import org.scalatest.matchers.must.Matchers 6 | import io.sphere.mongo.format.DefaultMongoFormats._ 7 | import io.sphere.mongo.MongoUtils._ 8 | import org.scalatest.wordspec.AnyWordSpec 9 | 10 | import scala.util.Try 11 | 12 | object MongoEmbeddedSpec { 13 | case class Embedded(value1: String, @MongoKey("_value2") value2: Int) 14 | 15 | object Embedded { 16 | implicit val mongo: MongoFormat[Embedded] = deriveMongoFormat 17 | } 18 | 19 | case class Test1(name: String, @MongoEmbedded embedded: Embedded) 20 | 21 | object Test1 { 22 | implicit val mongo: MongoFormat[Test1] = deriveMongoFormat 23 | } 24 | 25 | case class Test2(name: String, @MongoEmbedded embedded: Option[Embedded] = None) 26 | 27 | object Test2 { 28 | implicit val mongo: MongoFormat[Test2] = deriveMongoFormat 29 | } 30 | 31 | case class Test3( 32 | @MongoIgnore name: String = "default", 33 | @MongoEmbedded embedded: Option[Embedded] = None) 34 | 35 | object Test3 { 36 | implicit val mongo: MongoFormat[Test3] = deriveMongoFormat 37 | } 38 | 39 | case class SubTest4(@MongoEmbedded embedded: Embedded) 40 | object SubTest4 { 41 | implicit val mongo: MongoFormat[SubTest4] = deriveMongoFormat 42 | } 43 | 44 | case class Test4(subField: Option[SubTest4] = None) 45 | object Test4 { 46 | implicit val mongo: MongoFormat[Test4] = deriveMongoFormat 47 | } 48 | } 49 | 50 | class MongoEmbeddedSpec extends AnyWordSpec with Matchers with OptionValues { 51 | import MongoEmbeddedSpec._ 52 | 53 | "MongoEmbedded" should { 54 | "flatten the db object in one object" in { 55 | val dbo = dbObj( 56 | "name" -> "ze name", 57 | "value1" -> "ze value1", 58 | "_value2" -> 45 59 | ) 60 | val test1 = MongoFormat[Test1].fromMongoValue(dbo) 61 | test1.name mustEqual "ze name" 62 | test1.embedded.value1 mustEqual "ze value1" 63 | test1.embedded.value2 mustEqual 45 64 | 65 | val result = MongoFormat[Test1].toMongoValue(test1) 66 | result mustEqual dbo 67 | } 68 | 69 | "validate that the db object contains all needed fields" in { 70 | val dbo = dbObj( 71 | "name" -> "ze name", 72 | "value1" -> "ze value1" 73 | ) 74 | Try(MongoFormat[Test1].fromMongoValue(dbo)).isFailure must be(true) 75 | } 76 | 77 | "support optional embedded attribute" in { 78 | val dbo = dbObj( 79 | "name" -> "ze name", 80 | "value1" -> "ze value1", 81 | "_value2" -> 45 82 | ) 83 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 84 | test2.name mustEqual "ze name" 85 | test2.embedded.value.value1 mustEqual "ze value1" 86 | test2.embedded.value.value2 mustEqual 45 87 | 88 | val result = MongoFormat[Test2].toMongoValue(test2) 89 | result mustEqual dbo 90 | } 91 | 92 | "ignore unknown fields" in { 93 | val dbo = dbObj( 94 | "name" -> "ze name", 95 | "value1" -> "ze value1", 96 | "_value2" -> 45, 97 | "value4" -> true 98 | ) 99 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 100 | test2.name mustEqual "ze name" 101 | test2.embedded.value.value1 mustEqual "ze value1" 102 | test2.embedded.value.value2 mustEqual 45 103 | } 104 | 105 | "ignore ignored fields" in { 106 | val dbo = dbObj( 107 | "value1" -> "ze value1", 108 | "_value2" -> 45 109 | ) 110 | val test3 = MongoFormat[Test3].fromMongoValue(dbo) 111 | test3.name mustEqual "default" 112 | test3.embedded.value.value1 mustEqual "ze value1" 113 | test3.embedded.value.value2 mustEqual 45 114 | } 115 | 116 | "check for sub-fields" in { 117 | val dbo = dbObj( 118 | "subField" -> dbObj( 119 | "value1" -> "ze value1", 120 | "_value2" -> 45 121 | ) 122 | ) 123 | val test4 = MongoFormat[Test4].fromMongoValue(dbo) 124 | test4.subField.value.embedded.value1 mustEqual "ze value1" 125 | test4.subField.value.embedded.value2 mustEqual 45 126 | } 127 | 128 | "support the absence of optional embedded attribute" in { 129 | val dbo = dbObj( 130 | "name" -> "ze name" 131 | ) 132 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 133 | test2.name mustEqual "ze name" 134 | test2.embedded mustEqual None 135 | } 136 | 137 | "validate the absence of some embedded attributes" in { 138 | val dbo = dbObj( 139 | "name" -> "ze name", 140 | "value1" -> "ze value1" 141 | ) 142 | Try(MongoFormat[Test2].fromMongoValue(dbo)).isFailure must be(true) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.DefaultMongoFormats._ 4 | import io.sphere.mongo.format.MongoFormat 5 | import org.bson.BSONObject 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | import scala.collection.JavaConverters._ 10 | 11 | class MongoKeySpec extends AnyWordSpec with Matchers { 12 | import MongoKeySpec._ 13 | 14 | "deriving MongoFormat" must { 15 | "rename fields annotated with @MongoKey" in { 16 | val test = 17 | Test(value1 = "value1", value2 = "value2", subTest = SubTest(value2 = "other_value2")) 18 | 19 | val dbo = MongoFormat[Test].toMongoValue(test) 20 | val map = dbo.asInstanceOf[BSONObject].toMap.asScala.toMap[Any, Any] 21 | map.get("value1") must equal(Some("value1")) 22 | map.get("value2") must equal(None) 23 | map.get("new_value_2") must equal(Some("value2")) 24 | map.get("new_sub_value_2") must equal(Some("other_value2")) 25 | 26 | val newTest = MongoFormat[Test].fromMongoValue(dbo) 27 | newTest must be(test) 28 | } 29 | } 30 | } 31 | 32 | object MongoKeySpec { 33 | case class SubTest( 34 | @MongoKey("new_sub_value_2") value2: String 35 | ) 36 | object SubTest { 37 | implicit val mongo: MongoFormat[SubTest] = deriveMongoFormat 38 | } 39 | 40 | case class Test( 41 | value1: String, 42 | @MongoKey("new_value_2") value2: String, 43 | @MongoEmbedded subTest: SubTest 44 | ) 45 | object Test { 46 | implicit val mongo: MongoFormat[Test] = deriveMongoFormat 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithAbstractClassSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils.dbObj 4 | import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | 9 | class MongoTypeHintFieldWithAbstractClassSpec extends AnyWordSpec with Matchers { 10 | import MongoTypeHintFieldWithAbstractClassSpec._ 11 | 12 | "MongoTypeHintField (with abstract class)" must { 13 | "allow to set another field to distinguish between types (toMongo)" in { 14 | val user = UserWithPicture("foo-123", Medium, "http://example.com") 15 | val expected = dbObj( 16 | "userId" -> "foo-123", 17 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 18 | "pictureUrl" -> "http://example.com") 19 | 20 | val dbo = toMongo[UserWithPicture](user) 21 | dbo must be(expected) 22 | } 23 | 24 | "allow to set another field to distinguish between types (fromMongo)" in { 25 | val initialDbo = dbObj( 26 | "userId" -> "foo-123", 27 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 28 | "pictureUrl" -> "http://example.com") 29 | 30 | val user = fromMongo[UserWithPicture](initialDbo) 31 | 32 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 33 | 34 | val dbo = toMongo[UserWithPicture](user) 35 | dbo must be(initialDbo) 36 | } 37 | } 38 | } 39 | 40 | object MongoTypeHintFieldWithAbstractClassSpec { 41 | 42 | @MongoTypeHintField(value = "pictureType") 43 | sealed abstract class PictureSize 44 | case object Small extends PictureSize 45 | case object Medium extends PictureSize 46 | case object Big extends PictureSize 47 | 48 | object PictureSize { 49 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 50 | } 51 | 52 | case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) 53 | 54 | object UserWithPicture { 55 | implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithSealedTraitSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils.dbObj 4 | import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | 9 | class MongoTypeHintFieldWithSealedTraitSpec extends AnyWordSpec with Matchers { 10 | import MongoTypeHintFieldWithSealedTraitSpec._ 11 | 12 | "MongoTypeHintField (with sealed trait)" must { 13 | "allow to set another field to distinguish between types (toMongo)" in { 14 | val user = UserWithPicture("foo-123", Medium, "http://example.com") 15 | val expected = dbObj( 16 | "userId" -> "foo-123", 17 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 18 | "pictureUrl" -> "http://example.com") 19 | 20 | val dbo = toMongo[UserWithPicture](user) 21 | dbo must be(expected) 22 | } 23 | 24 | "allow to set another field to distinguish between types (fromMongo)" in { 25 | val initialDbo = dbObj( 26 | "userId" -> "foo-123", 27 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 28 | "pictureUrl" -> "http://example.com") 29 | 30 | val user = fromMongo[UserWithPicture](initialDbo) 31 | 32 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 33 | 34 | val dbo = toMongo[UserWithPicture](user) 35 | dbo must be(initialDbo) 36 | } 37 | } 38 | } 39 | 40 | object MongoTypeHintFieldWithSealedTraitSpec { 41 | 42 | @MongoTypeHintField(value = "pictureType") 43 | sealed trait PictureSize 44 | case object Small extends PictureSize 45 | case object Medium extends PictureSize 46 | case object Big extends PictureSize 47 | 48 | object PictureSize { 49 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 50 | } 51 | 52 | case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) 53 | 54 | object UserWithPicture { 55 | implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import com.mongodb.DBObject 4 | import org.scalatest.matchers.must.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import io.sphere.mongo.MongoUtils.dbObj 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | import io.sphere.mongo.format.MongoFormat 9 | import org.scalatest.Assertion 10 | 11 | class SumTypesDerivingSpec extends AnyWordSpec with Matchers { 12 | import SumTypesDerivingSpec._ 13 | 14 | "Serializing sum types" must { 15 | "use 'type' as default field" in { 16 | check(Color1.format, Color1.Red, dbObj("type" -> "Red")) 17 | 18 | check(Color1.format, Color1.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) 19 | } 20 | 21 | "use custom field" in { 22 | check(Color2.format, Color2.Red, dbObj("color" -> "Red")) 23 | 24 | check(Color2.format, Color2.Custom("2356"), dbObj("color" -> "Custom", "rgb" -> "2356")) 25 | } 26 | 27 | "use custom values" in { 28 | check(Color3.format, Color3.Red, dbObj("type" -> "red")) 29 | 30 | check(Color3.format, Color3.Custom("2356"), dbObj("type" -> "custom", "rgb" -> "2356")) 31 | } 32 | 33 | "use custom field & values" in pendingUntilFixed { 34 | check(Color4.format, Color4.Red, dbObj("color" -> "red")) 35 | 36 | check(Color4.format, Color4.Custom("2356"), dbObj("color" -> "custom", "rgb" -> "2356")) 37 | } 38 | 39 | "not allow specifying different custom field" in pendingUntilFixed { 40 | // to serialize Custom, should we use type "color" or "color-custom"? 41 | "deriveMongoFormat[Color5]" mustNot compile 42 | } 43 | 44 | "not allow specifying different custom field on intermediate level" in { 45 | // to serialize Custom, should we use type "color" or "color-custom"? 46 | "deriveMongoFormat[Color6]" mustNot compile 47 | } 48 | 49 | "use intermediate level" in { 50 | deriveMongoFormat[Color7] 51 | } 52 | 53 | "do not use sealed trait info when using a case class directly" in { 54 | check(Color8.format, Color8.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) 55 | 56 | check(Color8.Custom.format, Color8.Custom("2356"), dbObj("rgb" -> "2356")) 57 | 58 | // unless annotated 59 | 60 | check( 61 | Color8.format, 62 | Color8.CustomAnnotated("2356"), 63 | dbObj("type" -> "CustomAnnotated", "rgb" -> "2356")) 64 | 65 | check( 66 | Color8.CustomAnnotated.format, 67 | Color8.CustomAnnotated("2356"), 68 | dbObj("type" -> "CustomAnnotated", "rgb" -> "2356")) 69 | } 70 | 71 | "use default values if custom values are empty" in { 72 | check(Color9.format, Color9.Red, dbObj("type" -> "Red")) 73 | 74 | check(Color9.format, Color9.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) 75 | } 76 | } 77 | 78 | } 79 | 80 | object SumTypesDerivingSpec { 81 | import Matchers._ 82 | 83 | def check[A, B <: A](format: MongoFormat[A], b: B, dbo: DBObject): Assertion = { 84 | val serialized = format.toMongoValue(b) 85 | serialized must be(dbo) 86 | 87 | format.fromMongoValue(serialized) must be(b) 88 | } 89 | 90 | sealed trait Color1 91 | object Color1 { 92 | case object Red extends Color1 93 | case class Custom(rgb: String) extends Color1 94 | val format = deriveMongoFormat[Color1] 95 | } 96 | 97 | @MongoTypeHintField("color") 98 | sealed trait Color2 99 | object Color2 { 100 | case object Red extends Color2 101 | case class Custom(rgb: String) extends Color2 102 | val format = deriveMongoFormat[Color2] 103 | } 104 | 105 | sealed trait Color3 106 | object Color3 { 107 | @MongoTypeHint("red") case object Red extends Color3 108 | @MongoTypeHint("custom") case class Custom(rgb: String) extends Color3 109 | val format = deriveMongoFormat[Color3] 110 | } 111 | 112 | @MongoTypeHintField("color") 113 | sealed trait Color4 114 | object Color4 { 115 | @MongoTypeHint("red") case object Red extends Color4 116 | @MongoTypeHint("custom") case class Custom(rgb: String) extends Color4 117 | val format = deriveMongoFormat[Color4] 118 | } 119 | 120 | @MongoTypeHintField("color") 121 | sealed trait Color5 122 | object Color5 { 123 | @MongoTypeHint("red") 124 | case object Red extends Color5 125 | @MongoTypeHintField("color-custom") 126 | @MongoTypeHint("custom") 127 | case class Custom(rgb: String) extends Color5 128 | } 129 | 130 | @MongoTypeHintField("color") 131 | sealed trait Color6 132 | object Color6 { 133 | @MongoTypeHintField("color-custom") 134 | abstract class MyColor extends Color6 135 | @MongoTypeHint("red") 136 | case object Red extends MyColor 137 | @MongoTypeHint("custom") 138 | case class Custom(rgb: String) extends MyColor 139 | } 140 | 141 | sealed trait Color7 142 | sealed trait Color7a extends Color7 143 | object Color7 { 144 | case object Red extends Color7a 145 | case class Custom(rgb: String) extends Color7a 146 | } 147 | 148 | sealed trait Color8 149 | object Color8 { 150 | case object Red extends Color8 151 | case class Custom(rgb: String) extends Color8 152 | object Custom { 153 | val format = deriveMongoFormat[Custom] 154 | } 155 | @MongoTypeHintField("type") 156 | case class CustomAnnotated(rgb: String) extends Color8 157 | object CustomAnnotated { 158 | val format = deriveMongoFormat[CustomAnnotated] 159 | } 160 | val format = deriveMongoFormat[Color8] 161 | } 162 | 163 | sealed trait Color9 164 | object Color9 { 165 | @MongoTypeHint("") 166 | case object Red extends Color9 167 | @MongoTypeHint(" ") 168 | case class Custom(rgb: String) extends Color9 169 | val format = deriveMongoFormat[Color9] 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoEmbedded.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.METHOD; 8 | 9 | /** Specifies to embed / flatten all attributes of a nested object into the parent object. 10 | * This can be used to work around the 22 field limit of case classes without causing unwanted nesting. */ 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @Target({METHOD}) 13 | public @interface MongoEmbedded {} -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoIgnore.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.METHOD; 8 | 9 | /** Specifies to ignore a certain field on serialization. The field must have a default value. */ 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Target({METHOD}) 12 | public @interface MongoIgnore {} -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoKey.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.METHOD; 8 | 9 | /** Specifies the field name to use in Mongo, instead of defaulting to the field name of the case class. */ 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Target({METHOD}) 12 | public @interface MongoKey { 13 | String value(); 14 | } -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoProvidedFormatter.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Target; 4 | 5 | import static java.lang.annotation.ElementType.TYPE; 6 | 7 | /** 8 | * A subtype marked with MongoProvidedFormatter will use the custom provided formatter instead of the automatically generated one. 9 | */ 10 | @Target({TYPE}) 11 | public @interface MongoProvidedFormatter { 12 | } -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoTypeHint.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.TYPE; 8 | 9 | /** Specifies a type hint used to choose the correct type when deserializing 10 | * types that form a type hierarchy using a delegate converter. */ 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @Target({TYPE}) 13 | public @interface MongoTypeHint { 14 | String value() default ""; 15 | } -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/java/io/sphere/mongo/generic/annotations/MongoTypeHintField.java: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic.annotations; 2 | 3 | import java.lang.annotation.Inherited; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import static java.lang.annotation.ElementType.TYPE; 9 | 10 | /** Specifies name of the field that should be used as a type hint for delegate converters 11 | * when deserializing objects that form a class hierarchy. */ 12 | @Inherited 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Target({TYPE}) 15 | public @interface MongoTypeHintField { 16 | String value() default "type"; 17 | } -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/MongoFormatMacros.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.MongoFormat 4 | import io.sphere.mongo.generic.annotations.MongoProvidedFormatter 5 | 6 | import scala.reflect.macros.blackbox 7 | 8 | /** copy/paste from 9 | * https://github.com/sphereio/sphere-scala-libs/blob/master/json/src/main/scala/generic/JSONMacros.scala, 10 | * adapted to `MongoFormat`. 11 | */ 12 | private[generic] object MongoFormatMacros { 13 | private def collectKnownSubtypes(c: blackbox.Context)( 14 | s: c.universe.Symbol): Set[c.universe.Symbol] = 15 | if (s.isModule || s.isModuleClass) Set(s) 16 | else if (s.isClass) { 17 | val cs = s.asClass 18 | if (cs.isCaseClass) Set(cs) 19 | else if (cs.isTrait || cs.isAbstract) { 20 | if (cs.isSealed) { 21 | cs.knownDirectSubclasses.flatMap(collectKnownSubtypes(c)(_)) 22 | } else { 23 | c.abort( 24 | c.enclosingPosition, 25 | s"Can only enumerate values of a sealed trait or class, failed on: '${cs.name}'" 26 | ) 27 | } 28 | } else Set.empty 29 | } else Set.empty 30 | 31 | def mongoFormatProductApply(c: blackbox.Context)( 32 | tpe: c.universe.Type, 33 | classSym: c.universe.ClassSymbol): c.universe.Tree = { 34 | import c.universe._ 35 | 36 | if (classSym.isCaseClass && !classSym.isModuleClass) { 37 | val argList = classSym.toType.member(termNames.CONSTRUCTOR).asMethod.paramLists.head 38 | val modifiers = Modifiers(Flag.PARAM) 39 | val (argDefs, args) = (for ((a, i) <- argList.zipWithIndex) yield { 40 | val argType = classSym.toType.member(a.name).typeSignatureIn(tpe) 41 | val termName = TermName("x" + i) 42 | val argTree = ValDef(modifiers, termName, TypeTree(argType), EmptyTree) 43 | (argTree, Ident(termName)) 44 | }).unzip 45 | 46 | val applyBlock = Block( 47 | Nil, 48 | Function( 49 | argDefs, 50 | Apply(Select(Ident(classSym.companion), TermName("apply")), args) 51 | )) 52 | Apply( 53 | Select( 54 | reify(io.sphere.mongo.generic.`package`).tree, 55 | TermName("mongoProduct") 56 | ), 57 | applyBlock :: Nil 58 | ) 59 | } else if (classSym.isCaseClass && classSym.isModuleClass) { 60 | Apply( 61 | Select( 62 | reify(io.sphere.mongo.generic.`package`).tree, 63 | TermName("mongoProduct0") 64 | ), 65 | Ident(classSym.name.toTermName) :: Nil 66 | ) 67 | } else if (classSym.isModuleClass) { 68 | Apply( 69 | Select( 70 | reify(io.sphere.mongo.generic.`package`).tree, 71 | TermName("mongoSingleton") 72 | ), 73 | Ident(classSym.name.toTermName) :: Nil 74 | ) 75 | } else c.abort(c.enclosingPosition, "Not a case class or (case) object") 76 | } 77 | 78 | def deriveMongoFormat_impl[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[MongoFormat[A]] = { 79 | import c.universe._ 80 | 81 | val tpe = weakTypeOf[A] 82 | val symbol = tpe.typeSymbol 83 | 84 | if (tpe <:< weakTypeOf[Enumeration#Value]) { 85 | val TypeRef(pre, _, _) = tpe 86 | c.Expr[MongoFormat[A]]( 87 | Apply( 88 | Select( 89 | reify(io.sphere.mongo.generic.`package`).tree, 90 | TermName("mongoEnum") 91 | ), 92 | Ident(pre.typeSymbol.name.toTermName) :: Nil 93 | )) 94 | } else if (symbol.isClass && (symbol.asClass.isCaseClass || symbol.asClass.isModuleClass)) 95 | // product type or singleton 96 | c.Expr[MongoFormat[A]](mongoFormatProductApply(c)(tpe, symbol.asClass)) 97 | else { 98 | // sum type 99 | if (!symbol.isClass) 100 | c.abort( 101 | c.enclosingPosition, 102 | "Can only enumerate values of a sealed trait or class." 103 | ) 104 | else if (!symbol.asClass.isSealed) 105 | c.abort( 106 | c.enclosingPosition, 107 | "Can only enumerate values of a sealed trait or class." 108 | ) 109 | else { 110 | val subtypes = collectKnownSubtypes(c)(symbol) 111 | 112 | val (subtypesWithFormatter, subtypesWithNoFormatter) = 113 | subtypes.partition(mongoFormatExists(c)) 114 | val singleParamTypes = subtypesWithFormatter.filter(_.asType.typeParams.length == 1) 115 | 116 | val idents = Ident(symbol.name) :: (subtypes -- singleParamTypes).map { s => 117 | if (s.isModuleClass) New(TypeTree(s.asClass.toType)) else Ident(s.name) 118 | }.toList 119 | 120 | if (idents.size == 1) 121 | c.abort(c.enclosingPosition, "Subtypes not found.") 122 | else { 123 | val instanceDefs = subtypesWithNoFormatter.zipWithIndex.collect { 124 | case (symbol, i) if symbol.isClass && symbol.asClass.isCaseClass => 125 | if (symbol.asClass.typeParams.nonEmpty) { 126 | c.abort( 127 | c.enclosingPosition, 128 | "Types with type parameters cannot (yet) be derived as part of a sum type") 129 | } else { 130 | ValDef( 131 | Modifiers(Flag.IMPLICIT), 132 | TermName("mongo" + i), 133 | AppliedTypeTree( 134 | Ident(TypeName("MongoFormat")), 135 | Ident(symbol) :: Nil 136 | ), 137 | mongoFormatProductApply(c)(tpe, symbol.asClass) 138 | ) 139 | } 140 | }.toList 141 | 142 | val typeSelectors = singleParamTypes.map { t => 143 | val firstTypeParam = t.asType.typeParams.head 144 | // This code relies on type erasure. TypeSelectors are grouped by their Class[A] 145 | // But in case A has a type parameter it's erased anyway, 146 | // so the Map[Class[A[_].. currently cannot store multiple instances for a single type parameter type based on the type param 147 | // Until this changes we'd need to provide an Any instance or an upper bound instance anyway 148 | firstTypeParam.typeSignature match { 149 | case TypeBounds(_, superType) => 150 | q"new io.sphere.mongo.generic.TypeSelector[$t[$superType]](${t.name.toString}, classOf[$t[$superType]])" 151 | case _ => 152 | q"new io.sphere.mongo.generic.TypeSelector[$t[Any]](${t.name.toString}, classOf[$t[Any]])" 153 | } 154 | }.toList 155 | 156 | c.Expr[MongoFormat[A]]( 157 | Block( 158 | instanceDefs, 159 | Apply( 160 | TypeApply( 161 | Select( 162 | reify(io.sphere.mongo.generic.`package`).tree, 163 | TermName("mongoTypeSwitch") 164 | ), 165 | idents 166 | ), 167 | q"$typeSelectors" :: Nil 168 | ) 169 | ) 170 | ) 171 | } 172 | } 173 | } 174 | } 175 | private def mongoFormatExists(c: blackbox.Context)(s: c.universe.Symbol) = 176 | s.annotations.exists(_.tree.tpe =:= c.universe.typeOf[MongoProvidedFormatter]) 177 | } 178 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/MongoUtils.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | import com.mongodb.BasicDBObject 3 | 4 | object MongoUtils { 5 | 6 | def dbObj(pairs: (String, Any)*) = 7 | pairs.foldLeft(new BasicDBObject) { case (obj, (key, value)) => obj.append(key, value) } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/SerializationTest.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo 2 | 3 | import com.mongodb.{BasicDBObject, DBObject} 4 | import org.scalatest.matchers.must.Matchers 5 | import io.sphere.mongo.format.MongoFormat 6 | import io.sphere.mongo.format.DefaultMongoFormats._ 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | object SerializationTest { 10 | case class Something(a: Option[Int], b: Int = 2) 11 | 12 | object Color extends Enumeration { 13 | val Blue, Red, Yellow = Value 14 | } 15 | } 16 | 17 | class SerializationTest extends AnyWordSpec with Matchers { 18 | import SerializationTest._ 19 | 20 | "mongoProduct" must { 21 | "deserialize mongo object" in { 22 | val dbo = new BasicDBObject() 23 | dbo.put("a", Integer.valueOf(3)) 24 | dbo.put("b", Integer.valueOf(4)) 25 | 26 | val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat 27 | val something = mongoFormat.fromMongoValue(dbo) 28 | something must be(Something(Some(3), 4)) 29 | } 30 | 31 | "generate a format that serializes optional fields with value None as BSON objects without that field" in { 32 | val testFormat: MongoFormat[Something] = 33 | io.sphere.mongo.generic.mongoProduct[Something, Option[Int], Int] { 34 | (a: Option[Int], b: Int) => Something(a, b) 35 | } 36 | 37 | val serializedObject = testFormat.toMongoValue(Something(None, 1)).asInstanceOf[DBObject] 38 | serializedObject.keySet().contains("b") must be(true) 39 | serializedObject.keySet().contains("a") must be(false) 40 | } 41 | 42 | "generate a format that use default values" in { 43 | val dbo = new BasicDBObject() 44 | dbo.put("a", Integer.valueOf(3)) 45 | 46 | val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat 47 | val something = mongoFormat.fromMongoValue(dbo) 48 | something must be(Something(Some(3), 2)) 49 | } 50 | } 51 | 52 | "mongoEnum" must { 53 | "serialize and deserialize enums" in { 54 | val mongo: MongoFormat[Color.Value] = generic.mongoEnum(Color) 55 | 56 | // mongo java driver knows how to encode/decode Strings 57 | val serializedObject = mongo.toMongoValue(Color.Red).asInstanceOf[String] 58 | serializedObject must be("Red") 59 | 60 | val enumValue = mongo.fromMongoValue(serializedObject) 61 | enumValue must be(Color.Red) 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.format 2 | 3 | import io.sphere.mongo.generic._ 4 | import org.scalatest.OptionValues 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.MongoUtils._ 8 | import DefaultMongoFormats._ 9 | 10 | object OptionMongoFormatSpec { 11 | 12 | case class SimpleClass(value1: String, value2: Int) 13 | 14 | object SimpleClass { 15 | implicit val mongo: MongoFormat[SimpleClass] = mongoProduct(apply _) 16 | } 17 | 18 | case class ComplexClass(name: String, simpleClass: Option[SimpleClass]) 19 | 20 | object ComplexClass { 21 | implicit val mongo: MongoFormat[ComplexClass] = mongoProduct(apply _) 22 | } 23 | 24 | } 25 | 26 | class OptionMongoFormatSpec extends AnyWordSpec with Matchers with OptionValues { 27 | import OptionMongoFormatSpec._ 28 | 29 | "MongoFormat[Option[_]]" should { 30 | "handle presence of all fields" in { 31 | val dbo = dbObj( 32 | "value1" -> "a", 33 | "value2" -> 45 34 | ) 35 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 36 | result.value.value1 mustEqual "a" 37 | result.value.value2 mustEqual 45 38 | } 39 | 40 | "handle presence of all fields mixed with ignored fields" in { 41 | val dbo = dbObj( 42 | "value1" -> "a", 43 | "value2" -> 45, 44 | "value3" -> "b" 45 | ) 46 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 47 | result.value.value1 mustEqual "a" 48 | result.value.value2 mustEqual 45 49 | } 50 | 51 | "handle presence of not all the fields" in { 52 | val dbo = dbObj("value1" -> "a") 53 | an[Exception] mustBe thrownBy(MongoFormat[Option[SimpleClass]].fromMongoValue(dbo)) 54 | } 55 | 56 | "handle absence of all fields" in { 57 | val dbo = dbObj() 58 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 59 | result mustEqual None 60 | } 61 | 62 | "handle absence of all fields mixed with ignored fields" in { 63 | val dbo = dbObj("value3" -> "a") 64 | val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) 65 | result mustEqual None 66 | } 67 | 68 | "consider all fields if the data type does not impose any restriction" in { 69 | val dbo = dbObj( 70 | "key1" -> "value1", 71 | "key2" -> "value2" 72 | ) 73 | val expected = Map("key1" -> "value1", "key2" -> "value2") 74 | val result = MongoFormat[Map[String, String]].fromMongoValue(dbo) 75 | result mustEqual expected 76 | 77 | val maybeResult = MongoFormat[Option[Map[String, String]]].fromMongoValue(dbo) 78 | maybeResult.value mustEqual expected 79 | } 80 | 81 | "parse optional element" in { 82 | val dbo = dbObj( 83 | "name" -> "ze name", 84 | "simpleClass" -> dbObj( 85 | "value1" -> "value1", 86 | "value2" -> 42 87 | ) 88 | ) 89 | val result = MongoFormat[ComplexClass].fromMongoValue(dbo) 90 | result.simpleClass.value.value1 mustEqual "value1" 91 | result.simpleClass.value.value2 mustEqual 42 92 | 93 | MongoFormat[ComplexClass].toMongoValue(result) mustEqual dbo 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DefaultValuesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils._ 4 | import io.sphere.mongo.format.DefaultMongoFormats._ 5 | import io.sphere.mongo.format.MongoFormat 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | class DefaultValuesSpec extends AnyWordSpec with Matchers { 10 | import DefaultValuesSpec._ 11 | 12 | "deriving MongoFormat" must { 13 | "handle default values" in { 14 | val dbo = dbObj() 15 | val test = MongoFormat[Test].fromMongoValue(dbo) 16 | test.value1 must be("hello") 17 | test.value2 must be(None) 18 | test.value3 must be(None) 19 | test.value4 must be(Some("hi")) 20 | } 21 | } 22 | } 23 | 24 | object DefaultValuesSpec { 25 | case class Test( 26 | value1: String = "hello", 27 | value2: Option[String], 28 | value3: Option[String] = None, 29 | value4: Option[String] = Some("hi") 30 | ) 31 | object Test { 32 | implicit val mongo: MongoFormat[Test] = mongoProduct(apply _) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.MongoFormat 4 | import io.sphere.mongo.format._ 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | import io.sphere.mongo.MongoUtils._ 9 | 10 | class DeriveMongoformatSpec extends AnyWordSpec with Matchers { 11 | import DeriveMongoformatSpec._ 12 | 13 | "deriving MongoFormat" must { 14 | "read normal singleton values" in { 15 | val user = fromMongo[UserWithPicture]( 16 | dbObj( 17 | "userId" -> "foo-123", 18 | "pictureSize" -> dbObj("type" -> "Medium"), 19 | "pictureUrl" -> "http://example.com")) 20 | 21 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 22 | } 23 | 24 | "read custom singleton values" in { 25 | val user = fromMongo[UserWithPicture]( 26 | dbObj( 27 | "userId" -> "foo-123", 28 | "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), 29 | "pictureUrl" -> "http://example.com")) 30 | 31 | user must be(UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) 32 | } 33 | 34 | "fail to read if singleton value is unknown" in { 35 | a[Exception] must be thrownBy fromMongo[UserWithPicture]( 36 | dbObj( 37 | "userId" -> "foo-123", 38 | "pictureSize" -> dbObj("type" -> "Unknown"), 39 | "pictureUrl" -> "http://example.com")) 40 | } 41 | 42 | "write normal singleton values" in { 43 | val dbo = toMongo[UserWithPicture](UserWithPicture("foo-123", Medium, "http://example.com")) 44 | dbo must be( 45 | dbObj( 46 | "userId" -> "foo-123", 47 | "pictureSize" -> dbObj("type" -> "Medium"), 48 | "pictureUrl" -> "http://example.com")) 49 | } 50 | 51 | "write custom singleton values" in { 52 | val dbo = 53 | toMongo[UserWithPicture](UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) 54 | dbo must be( 55 | dbObj( 56 | "userId" -> "foo-123", 57 | "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), 58 | "pictureUrl" -> "http://example.com")) 59 | } 60 | 61 | "write and consequently read, which must produce the original value" in { 62 | val originalUser = UserWithPicture("foo-123", Medium, "http://exmple.com") 63 | val newUser = fromMongo[UserWithPicture](toMongo[UserWithPicture](originalUser)) 64 | 65 | newUser must be(originalUser) 66 | } 67 | 68 | "read and write sealed trait with only one subtype" in { 69 | val dbo = dbObj( 70 | "userId" -> "foo-123", 71 | "pictureSize" -> dbObj("type" -> "Medium"), 72 | "pictureUrl" -> "http://example.com", 73 | "access" -> dbObj("type" -> "Authorized", "project" -> "internal") 74 | ) 75 | val user = fromMongo[UserWithPicture](dbo) 76 | 77 | user must be( 78 | UserWithPicture( 79 | "foo-123", 80 | Medium, 81 | "http://example.com", 82 | Some(Access.Authorized("internal")))) 83 | val newDbo = toMongo[UserWithPicture](user) 84 | newDbo must be(dbo) 85 | 86 | val newUser = fromMongo[UserWithPicture](newDbo) 87 | newUser must be(user) 88 | } 89 | 90 | "fail to derive if trait is not sealed" in { 91 | // Sealed 92 | "implicit val mongo: MongoFormat[SealedSub] = deriveMongoFormat[SealedSub]" must compile 93 | // Not sealed 94 | "implicit val mongo: MongoFormat[NotSealed] = deriveMongoFormat[NotSealed]" mustNot compile 95 | // Sealed, but child is not sealed 96 | "implicit val mongo: MongoFormat[SealedParent] = deriveMongoFormat[SealedParent]" mustNot compile 97 | } 98 | } 99 | } 100 | 101 | object DeriveMongoformatSpec { 102 | sealed trait PictureSize 103 | case object Small extends PictureSize 104 | case object Medium extends PictureSize 105 | case object Big extends PictureSize 106 | @MongoTypeHint(value = "bar") 107 | case class Custom(width: Int, height: Int) extends PictureSize 108 | 109 | object PictureSize { 110 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 111 | } 112 | 113 | sealed trait Access 114 | object Access { 115 | // only one sub-type 116 | case class Authorized(project: String) extends Access 117 | 118 | implicit val mongo: MongoFormat[Access] = deriveMongoFormat 119 | } 120 | 121 | case class UserWithPicture( 122 | userId: String, 123 | pictureSize: PictureSize, 124 | pictureUrl: String, 125 | access: Option[Access] = None) 126 | 127 | object UserWithPicture { 128 | implicit val mongo: MongoFormat[UserWithPicture] = mongoProduct(apply _) 129 | } 130 | 131 | sealed trait SealedParent 132 | 133 | sealed trait SealedSub extends SealedParent 134 | case class Sub1(x: String) extends SealedSub 135 | case class Sub2(y: Int) extends SealedSub 136 | 137 | trait NotSealed extends SealedParent 138 | case class Sub3(x: String) extends NotSealed 139 | case class Sub4(y: Int) extends NotSealed 140 | } 141 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoEmbeddedSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.MongoFormat 4 | import org.scalatest.OptionValues 5 | import org.scalatest.matchers.must.Matchers 6 | import io.sphere.mongo.format.DefaultMongoFormats._ 7 | import io.sphere.mongo.MongoUtils._ 8 | import org.scalatest.wordspec.AnyWordSpec 9 | 10 | import scala.util.Try 11 | 12 | object MongoEmbeddedSpec { 13 | case class Embedded(value1: String, @MongoKey("_value2") value2: Int) 14 | 15 | object Embedded { 16 | implicit val mongo: MongoFormat[Embedded] = mongoProduct(apply _) 17 | } 18 | 19 | case class Test1(name: String, @MongoEmbedded embedded: Embedded) 20 | 21 | object Test1 { 22 | implicit val mongo: MongoFormat[Test1] = mongoProduct(apply _) 23 | } 24 | 25 | case class Test2(name: String, @MongoEmbedded embedded: Option[Embedded] = None) 26 | 27 | object Test2 { 28 | implicit val mongo: MongoFormat[Test2] = mongoProduct(apply _) 29 | } 30 | 31 | case class Test3( 32 | @MongoIgnore name: String = "default", 33 | @MongoEmbedded embedded: Option[Embedded] = None) 34 | 35 | object Test3 { 36 | implicit val mongo: MongoFormat[Test3] = mongoProduct(apply _) 37 | } 38 | 39 | case class SubTest4(@MongoEmbedded embedded: Embedded) 40 | object SubTest4 { 41 | implicit val mongo: MongoFormat[SubTest4] = mongoProduct(apply _) 42 | } 43 | 44 | case class Test4(subField: Option[SubTest4] = None) 45 | object Test4 { 46 | implicit val mongo: MongoFormat[Test4] = mongoProduct(apply _) 47 | } 48 | } 49 | 50 | class MongoEmbeddedSpec extends AnyWordSpec with Matchers with OptionValues { 51 | import MongoEmbeddedSpec._ 52 | 53 | "MongoEmbedded" should { 54 | "flatten the db object in one object" in { 55 | val dbo = dbObj( 56 | "name" -> "ze name", 57 | "value1" -> "ze value1", 58 | "_value2" -> 45 59 | ) 60 | val test1 = MongoFormat[Test1].fromMongoValue(dbo) 61 | test1.name mustEqual "ze name" 62 | test1.embedded.value1 mustEqual "ze value1" 63 | test1.embedded.value2 mustEqual 45 64 | 65 | val result = MongoFormat[Test1].toMongoValue(test1) 66 | result mustEqual dbo 67 | } 68 | 69 | "validate that the db object contains all needed fields" in { 70 | val dbo = dbObj( 71 | "name" -> "ze name", 72 | "value1" -> "ze value1" 73 | ) 74 | Try(MongoFormat[Test1].fromMongoValue(dbo)).isFailure must be(true) 75 | } 76 | 77 | "support optional embedded attribute" in { 78 | val dbo = dbObj( 79 | "name" -> "ze name", 80 | "value1" -> "ze value1", 81 | "_value2" -> 45 82 | ) 83 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 84 | test2.name mustEqual "ze name" 85 | test2.embedded.value.value1 mustEqual "ze value1" 86 | test2.embedded.value.value2 mustEqual 45 87 | 88 | val result = MongoFormat[Test2].toMongoValue(test2) 89 | result mustEqual dbo 90 | } 91 | 92 | "ignore unknown fields" in { 93 | val dbo = dbObj( 94 | "name" -> "ze name", 95 | "value1" -> "ze value1", 96 | "_value2" -> 45, 97 | "value4" -> true 98 | ) 99 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 100 | test2.name mustEqual "ze name" 101 | test2.embedded.value.value1 mustEqual "ze value1" 102 | test2.embedded.value.value2 mustEqual 45 103 | } 104 | 105 | "ignore ignored fields" in { 106 | val dbo = dbObj( 107 | "value1" -> "ze value1", 108 | "_value2" -> 45 109 | ) 110 | val test3 = MongoFormat[Test3].fromMongoValue(dbo) 111 | test3.name mustEqual "default" 112 | test3.embedded.value.value1 mustEqual "ze value1" 113 | test3.embedded.value.value2 mustEqual 45 114 | } 115 | 116 | "check for sub-fields" in { 117 | val dbo = dbObj( 118 | "subField" -> dbObj( 119 | "value1" -> "ze value1", 120 | "_value2" -> 45 121 | ) 122 | ) 123 | val test4 = MongoFormat[Test4].fromMongoValue(dbo) 124 | test4.subField.value.embedded.value1 mustEqual "ze value1" 125 | test4.subField.value.embedded.value2 mustEqual 45 126 | } 127 | 128 | "support the absence of optional embedded attribute" in { 129 | val dbo = dbObj( 130 | "name" -> "ze name" 131 | ) 132 | val test2 = MongoFormat[Test2].fromMongoValue(dbo) 133 | test2.name mustEqual "ze name" 134 | test2.embedded mustEqual None 135 | } 136 | 137 | "validate the absence of some embedded attributes" in { 138 | val dbo = dbObj( 139 | "name" -> "ze name", 140 | "value1" -> "ze value1" 141 | ) 142 | Try(MongoFormat[Test2].fromMongoValue(dbo)).isFailure must be(true) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.format.DefaultMongoFormats._ 4 | import io.sphere.mongo.format.MongoFormat 5 | import org.bson.BSONObject 6 | import org.scalatest.matchers.must.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | 9 | import scala.collection.JavaConverters._ 10 | 11 | class MongoKeySpec extends AnyWordSpec with Matchers { 12 | import MongoKeySpec._ 13 | 14 | "deriving MongoFormat" must { 15 | "rename fields annotated with @MongoKey" in { 16 | val test = 17 | Test(value1 = "value1", value2 = "value2", subTest = SubTest(value2 = "other_value2")) 18 | 19 | val dbo = MongoFormat[Test].toMongoValue(test) 20 | val map = dbo.asInstanceOf[BSONObject].toMap.asScala.toMap[Any, Any] 21 | map.get("value1") must equal(Some("value1")) 22 | map.get("value2") must equal(None) 23 | map.get("new_value_2") must equal(Some("value2")) 24 | map.get("new_sub_value_2") must equal(Some("other_value2")) 25 | 26 | val newTest = MongoFormat[Test].fromMongoValue(dbo) 27 | newTest must be(test) 28 | } 29 | } 30 | } 31 | 32 | object MongoKeySpec { 33 | case class SubTest( 34 | @MongoKey("new_sub_value_2") value2: String 35 | ) 36 | object SubTest { 37 | implicit val mongo: MongoFormat[SubTest] = mongoProduct(apply _) 38 | } 39 | 40 | case class Test( 41 | value1: String, 42 | @MongoKey("new_value_2") value2: String, 43 | @MongoEmbedded subTest: SubTest 44 | ) 45 | object Test { 46 | implicit val mongo: MongoFormat[Test] = mongoProduct(apply _) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithAbstractClassSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils.dbObj 4 | import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | 9 | class MongoTypeHintFieldWithAbstractClassSpec extends AnyWordSpec with Matchers { 10 | import MongoTypeHintFieldWithAbstractClassSpec._ 11 | 12 | "MongoTypeHintField (with abstract class)" must { 13 | "allow to set another field to distinguish between types (toMongo)" in { 14 | val user = UserWithPicture("foo-123", Medium, "http://example.com") 15 | val expected = dbObj( 16 | "userId" -> "foo-123", 17 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 18 | "pictureUrl" -> "http://example.com") 19 | 20 | val dbo = toMongo[UserWithPicture](user) 21 | dbo must be(expected) 22 | } 23 | 24 | "allow to set another field to distinguish between types (fromMongo)" in { 25 | val initialDbo = dbObj( 26 | "userId" -> "foo-123", 27 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 28 | "pictureUrl" -> "http://example.com") 29 | 30 | val user = fromMongo[UserWithPicture](initialDbo) 31 | 32 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 33 | 34 | val dbo = toMongo[UserWithPicture](user) 35 | dbo must be(initialDbo) 36 | } 37 | } 38 | } 39 | 40 | object MongoTypeHintFieldWithAbstractClassSpec { 41 | 42 | @MongoTypeHintField(value = "pictureType") 43 | sealed abstract class PictureSize 44 | case object Small extends PictureSize 45 | case object Medium extends PictureSize 46 | case object Big extends PictureSize 47 | 48 | object PictureSize { 49 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 50 | } 51 | 52 | case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) 53 | 54 | object UserWithPicture { 55 | implicit val mongo: MongoFormat[UserWithPicture] = mongoProduct(apply _) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithSealedTraitSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.mongo.generic 2 | 3 | import io.sphere.mongo.MongoUtils.dbObj 4 | import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} 5 | import org.scalatest.matchers.must.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import io.sphere.mongo.format.DefaultMongoFormats._ 8 | 9 | class MongoTypeHintFieldWithSealedTraitSpec extends AnyWordSpec with Matchers { 10 | import MongoTypeHintFieldWithSealedTraitSpec._ 11 | 12 | "MongoTypeHintField (with sealed trait)" must { 13 | "allow to set another field to distinguish between types (toMongo)" in { 14 | val user = UserWithPicture("foo-123", Medium, "http://example.com") 15 | val expected = dbObj( 16 | "userId" -> "foo-123", 17 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 18 | "pictureUrl" -> "http://example.com") 19 | 20 | val dbo = toMongo[UserWithPicture](user) 21 | dbo must be(expected) 22 | } 23 | 24 | "allow to set another field to distinguish between types (fromMongo)" in { 25 | val initialDbo = dbObj( 26 | "userId" -> "foo-123", 27 | "pictureSize" -> dbObj("pictureType" -> "Medium"), 28 | "pictureUrl" -> "http://example.com") 29 | 30 | val user = fromMongo[UserWithPicture](initialDbo) 31 | 32 | user must be(UserWithPicture("foo-123", Medium, "http://example.com")) 33 | 34 | val dbo = toMongo[UserWithPicture](user) 35 | dbo must be(initialDbo) 36 | } 37 | } 38 | } 39 | 40 | object MongoTypeHintFieldWithSealedTraitSpec { 41 | 42 | // issue https://github.com/commercetools/sphere-scala-libs/issues/10 43 | // @MongoTypeHintField must be repeated for all sub-classes 44 | @MongoTypeHintField(value = "pictureType") 45 | sealed trait PictureSize 46 | @MongoTypeHintField(value = "pictureType") 47 | case object Small extends PictureSize 48 | @MongoTypeHintField(value = "pictureType") 49 | case object Medium extends PictureSize 50 | @MongoTypeHintField(value = "pictureType") 51 | case object Big extends PictureSize 52 | 53 | object PictureSize { 54 | implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] 55 | } 56 | 57 | case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) 58 | 59 | object UserWithPicture { 60 | implicit val mongo: MongoFormat[UserWithPicture] = mongoProduct(apply _) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /project/Fmpp.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | 4 | import java.io.File 5 | 6 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 7 | // Code generation via FMPP 8 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | // inspiration: https://github.com/sbt/sbt-fmpp/blob/master/src/main/scala/FmppPlugin.scala 10 | 11 | object Fmpp { 12 | private lazy val fmpp = TaskKey[Seq[File]]("fmpp") 13 | private lazy val fmppOptions = SettingKey[Seq[String]]("fmppOptions") 14 | private lazy val FmppConfig = config("fmpp") hide 15 | 16 | lazy val settings = fmppConfigSettings(Compile) ++ Seq( 17 | libraryDependencies += "net.sourceforge.fmpp" % "fmpp" % "0.9.16" % FmppConfig.name, 18 | ivyConfigurations += FmppConfig, 19 | FmppConfig / fullClasspath := update.value 20 | .select(configurationFilter(FmppConfig.name)) 21 | .map(Attributed.blank) 22 | ) 23 | 24 | private def fmppConfigSettings(c: Configuration): Seq[Setting[_]] = inConfig(c)( 25 | Seq( 26 | Compile / sourceGenerators += fmpp.taskValue, 27 | fmpp := fmppTask.value, 28 | sources := managedSources.value 29 | )) 30 | 31 | private val fmppTask = Def.task { 32 | val cp = (FmppConfig / fullClasspath).value 33 | val r = (fmpp / runner).value 34 | val srcRoot = baseDirectory.value / (Defaults.prefix(configuration.value.name) + "src") 35 | val output = sourceManaged.value 36 | val s = streams.value 37 | val cache = s.cacheDirectory 38 | 39 | val cached = FileFunction.cached(cache / "fmpp", FilesInfo.lastModified, FilesInfo.exists) { 40 | (in: Set[File]) => 41 | IO.delete(output) 42 | val arguments = 43 | "-U" +: "all" +: "-S" +: srcRoot.getAbsolutePath +: "-O" +: output.getAbsolutePath +: 44 | "-M" +: "ignore(test/**,it/**),execute(**/*.fmpp.scala),copy(**/*)" +: Seq.empty 45 | r.run("fmpp.tools.CommandLine", cp.files, arguments, s.log) 46 | .failed 47 | .foreach(sys error _.getMessage) 48 | (output ** "*.scala").get.toSet ++ (output ** "*.java").get.toSet 49 | } 50 | cached((srcRoot ** "*.*").get.toSet).toSeq 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | 3 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // https://github.com/sbt/sbt-boilerplate/releases 2 | addSbtPlugin("com.github.sbt" % "sbt-boilerplate" % "0.7.0") 3 | 4 | // https://github.com/ktoso/sbt-jmh/releases 5 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") 6 | 7 | // https://github.com/djspiewak/sbt-github-actions/releases 8 | addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.25.0") 9 | 10 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 11 | 12 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.1") 13 | -------------------------------------------------------------------------------- /util/dependencies.sbt: -------------------------------------------------------------------------------- 1 | libraryDependencies ++= Seq( 2 | "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4", 3 | "joda-time" % "joda-time" % "2.13.0", 4 | "org.joda" % "joda-convert" % "3.0.1", 5 | "org.typelevel" %% "cats-core" % "2.13.0", 6 | "org.json4s" %% "json4s-scalap" % "4.0.7" 7 | ) 8 | -------------------------------------------------------------------------------- /util/src/main/scala/Concurrent.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import java.util.concurrent.ThreadFactory 4 | import java.util.concurrent.atomic.AtomicInteger 5 | 6 | object Concurrent { 7 | def namedThreadFactory(poolName: String): ThreadFactory = 8 | new ThreadFactory { 9 | val count = new AtomicInteger(0) 10 | override def newThread(r: Runnable) = 11 | new Thread(r, poolName + "-" + count.incrementAndGet) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /util/src/main/scala/DateTimeFormats.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import scala.util.Try 4 | 5 | import java.time.format._ 6 | import java.time.temporal.ChronoField 7 | import java.time.LocalDate 8 | import java.time.LocalTime 9 | import java.time.LocalDateTime 10 | import java.time.ZonedDateTime 11 | import java.time.ZoneId 12 | import java.time.ZoneOffset 13 | import java.time.OffsetDateTime 14 | import java.time.Instant 15 | 16 | object DateTimeFormats { 17 | 18 | /** Canonical format for writing dates. 19 | * 20 | * Very close to the default ISO_LOCAL_DATE formatter with the difference that the sign in front 21 | * of the year is only used to mark negative years (i.e. no + for years with more than 4 digits) 22 | */ 23 | private val localDateFormatter: DateTimeFormatter = new DateTimeFormatterBuilder() 24 | .appendValue(ChronoField.YEAR, 4, 9, SignStyle.NORMAL) 25 | .appendLiteral('-') 26 | .appendValue(ChronoField.MONTH_OF_YEAR, 2) 27 | .appendLiteral('-') 28 | .appendValue(ChronoField.DAY_OF_MONTH, 2) 29 | .toFormatter(); 30 | 31 | /** Canonical format for writing times. 32 | */ 33 | private val localTimeFormatter: DateTimeFormatter = new DateTimeFormatterBuilder() 34 | .appendValue(ChronoField.HOUR_OF_DAY, 2) 35 | .appendLiteral(':') 36 | .appendValue(ChronoField.MINUTE_OF_HOUR, 2) 37 | .appendLiteral(':') 38 | .appendValue(ChronoField.SECOND_OF_MINUTE, 2) 39 | .appendLiteral('.') 40 | .appendValue(ChronoField.MILLI_OF_SECOND, 3) 41 | .toFormatter() 42 | 43 | /** Canonical format for writing date-times in UTC 44 | */ 45 | private val localDateTimeFormatter = new DateTimeFormatterBuilder() 46 | .append(localDateFormatter) 47 | .appendLiteral('T') 48 | .append(localTimeFormatter) 49 | .appendLiteral('Z') 50 | .toFormatter 51 | 52 | def format(date: LocalDate): String = localDateFormatter.format(date) 53 | def format(time: LocalTime): String = localTimeFormatter.format(time) 54 | def format(dateTime: LocalDateTime): String = 55 | localDateTimeFormatter.format(dateTime.atOffset(ZoneOffset.UTC).toLocalDateTime()) 56 | def format(dateTime: ZonedDateTime): String = 57 | localDateTimeFormatter.format(dateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()) 58 | def format(dateTime: OffsetDateTime): String = 59 | localDateTimeFormatter.format(dateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()) 60 | def format(instant: Instant): String = 61 | localDateTimeFormatter.format(instant.atZone(ZoneOffset.UTC).toLocalDateTime()) 62 | 63 | /** This is adapted from 64 | * https://github.com/commercetools/sphere-scala-libs/blob/dcb270c4be706b8c8b84d43821e48d1982fd6c37/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala#L408 65 | * 66 | * The original [[LocalDate]] parser was created to retain the leniency of Joda after migrating 67 | * from Joda to [[java.time]] in Sphere. java.time operates more strictly, so this parser ensures 68 | * a very high level in flexibility 69 | */ 70 | private val lenientLocalDateParser: DateTimeFormatter = new DateTimeFormatterBuilder() 71 | .optionalStart() 72 | .appendLiteral('+') 73 | .optionalEnd() 74 | .appendValue(ChronoField.YEAR, 1, 9, SignStyle.NORMAL) 75 | .optionalStart() 76 | .appendLiteral('-') 77 | .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NORMAL) 78 | .optionalStart() 79 | .appendLiteral('-') 80 | .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NORMAL) 81 | .optionalEnd() 82 | .optionalEnd() 83 | .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1L) 84 | .parseDefaulting(ChronoField.DAY_OF_MONTH, 1L) 85 | .toFormatter() 86 | 87 | // Simplified version of jodatime's `ISODateTimeFormat.localTimeParser` 88 | private val lenientLocalTimeParser: DateTimeFormatter = new DateTimeFormatterBuilder() 89 | .appendValue(ChronoField.HOUR_OF_DAY, 2) 90 | .optionalStart() 91 | .appendLiteral(':') 92 | .appendValue(ChronoField.MINUTE_OF_HOUR, 2) 93 | .optionalStart() 94 | .appendLiteral(':') 95 | .appendValue(ChronoField.SECOND_OF_MINUTE, 2) 96 | .optionalEnd() 97 | .optionalEnd() 98 | .optionalStart() 99 | .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) 100 | .optionalEnd() 101 | .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) 102 | .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) 103 | .parseDefaulting(ChronoField.NANO_OF_SECOND, 0) 104 | .toFormatter() 105 | 106 | /** Strict version of jodatime's `ISODateTimeFormat.dateTimeParser` 107 | * 108 | * Parse a ZonedDateTime defaulting to UTC using the lenient date and time parsers 109 | */ 110 | private val lenientDateTimeParser: DateTimeFormatter = 111 | new DateTimeFormatterBuilder() 112 | .append(lenientLocalDateParser) 113 | .optionalStart() 114 | .appendLiteral('T') 115 | .optionalStart() 116 | .append(lenientLocalTimeParser) 117 | .optionalEnd() 118 | .optionalStart() 119 | .optionalStart() 120 | .appendOffsetId() 121 | .optionalEnd() 122 | .optionalStart() 123 | .appendOffset("+HH:MM", "Z") 124 | .optionalEnd() 125 | .optionalStart() 126 | .appendOffset("+HHmm", "Z") 127 | .optionalEnd() 128 | .optionalEnd() 129 | .optionalEnd() 130 | .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) 131 | .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) 132 | .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) 133 | .parseDefaulting(ChronoField.NANO_OF_SECOND, 0) 134 | .parseDefaulting(ChronoField.OFFSET_SECONDS, 0) 135 | .toFormatter 136 | 137 | def parseLocalTime(time: String): Try[LocalTime] = 138 | Try(parseLocalTimeUnsafe(time)) 139 | def parseLocalTimeUnsafe(time: String): LocalTime = 140 | LocalTime.parse(time, lenientLocalTimeParser) 141 | 142 | def parseLocalDate(date: String): Try[LocalDate] = Try(parseLocalDateUnsafe(date)) 143 | def parseLocalDateUnsafe(date: String): LocalDate = 144 | LocalDate.parse(date, lenientLocalDateParser) 145 | 146 | def parseLocalDateTime(dateTime: String): Try[LocalDateTime] = 147 | Try(parseLocalDateTimeUnsafe(dateTime)) 148 | def parseLocalDateTimeUnsafe(dateTime: String): LocalDateTime = 149 | OffsetDateTime 150 | .parse(dateTime, lenientDateTimeParser) 151 | .withOffsetSameInstant(ZoneOffset.UTC) 152 | .toLocalDateTime 153 | 154 | def parseOffsetDateTime(dateTime: String): Try[OffsetDateTime] = 155 | Try(parseOffsetDateTimeUnsafe(dateTime)) 156 | def parseOffsetDateTimeUnsafe(dateTime: String): OffsetDateTime = 157 | OffsetDateTime.parse(dateTime, lenientDateTimeParser) 158 | 159 | def parseInstant(dateTime: String): Try[Instant] = 160 | Try(parseInstantUnsafe(dateTime)) 161 | 162 | def parseInstantUnsafe(dateTime: String): Instant = 163 | Instant.from(lenientDateTimeParser.parse(dateTime)) 164 | 165 | } 166 | -------------------------------------------------------------------------------- /util/src/main/scala/LangTag.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import java.util.Locale 4 | 5 | /** Extractor for Locales, e.g. for use in pattern-matching request paths. */ 6 | object LangTag { 7 | 8 | final val UNDEFINED: String = "und" 9 | 10 | class LocaleOpt(val locale: Locale) extends AnyVal { 11 | // if toLanguageTag returns "und", it means the language tag is undefined 12 | def isEmpty: Boolean = UNDEFINED == locale.toLanguageTag 13 | def get: Locale = locale 14 | } 15 | 16 | def unapply(s: String): LocaleOpt = new LocaleOpt(Locale.forLanguageTag(s)) 17 | 18 | def invalidLangTagMessage(invalidLangTag: String) = s"Invalid language tag: '$invalidLangTag'" 19 | } 20 | -------------------------------------------------------------------------------- /util/src/main/scala/Logging.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import com.typesafe.scalalogging.Logger 4 | import org.slf4j.LoggerFactory 5 | 6 | trait Logging { 7 | protected val log: Logger = 8 | Logger(LoggerFactory.getLogger(getClass.getName)) 9 | } 10 | -------------------------------------------------------------------------------- /util/src/main/scala/Memoizer.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import java.util.concurrent._ 4 | 5 | /** Straight port from the Java impl. of "Java Concurrency in Practice". */ 6 | final class Memoizer[K, V](action: K => V) extends (K => V) { 7 | private val cache = new ConcurrentHashMap[K, Future[V]] 8 | def apply(k: K): V = { 9 | while (true) { 10 | var f = cache.get(k) 11 | if (f == null) { 12 | val eval = new Callable[V] { def call(): V = action(k) } 13 | val ft = new FutureTask[V](eval) 14 | f = cache.putIfAbsent(k, ft) 15 | if (f == null) { 16 | f = ft 17 | ft.run() 18 | } 19 | } 20 | try return f.get 21 | catch { 22 | case _: CancellationException => cache.remove(k, f) 23 | case e: ExecutionException => throw e.getCause 24 | } 25 | } 26 | sys.error("Failed to compute result.") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /util/src/main/scala/Reflect.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import org.json4s.scalap.scalasig._ 4 | 5 | object Reflect extends Logging { 6 | case class CaseClassMeta(fields: IndexedSeq[CaseClassFieldMeta]) 7 | case class CaseClassFieldMeta(name: String, default: Option[Any] = None) 8 | 9 | /** Obtains minimal meta information about a case class or object via scalap. The meta information 10 | * contains a list of names and default values which represent the arguments of the case class 11 | * constructor and their default values, in the order they are defined. 12 | * 13 | * Note: Does not work for case classes or objects nested in other classes or traits (nesting 14 | * inside other objects is fine). Note: Only a single default value is obtained for each field. 15 | * Thus avoid default values that are different on each invocation (e.g. new DateTime()). In 16 | * other words, the case class constructors should be pure functions. 17 | */ 18 | val getCaseClassMeta = new Memoizer[Class[_], CaseClassMeta](clazz => { 19 | log.trace("Initializing reflection metadata for case class or object %s".format(clazz.getName)) 20 | CaseClassMeta(getCaseClassFieldMeta(clazz)) 21 | }) 22 | 23 | private def getCompanionClass(clazz: Class[_]): Class[_] = 24 | Class.forName(clazz.getName + "$", true, clazz.getClassLoader) 25 | private def getCompanionObject(companionClass: Class[_]): Object = 26 | companionClass.getField("MODULE$").get(null) 27 | private def getCaseClassFieldMeta(clazz: Class[_]): IndexedSeq[CaseClassFieldMeta] = 28 | if (clazz.getName.endsWith("$")) IndexedSeq.empty[CaseClassFieldMeta] 29 | else { 30 | val companionClass = getCompanionClass(clazz) 31 | val companionObject = getCompanionObject(companionClass) 32 | 33 | val maybeSym = clazz.getName.split("\\$") match { 34 | case Array(_) => ScalaSigParser.parse(clazz).flatMap(_.topLevelClasses.headOption) 35 | case Array(h, t @ _*) => 36 | val name = t.last 37 | val topSymbol = ScalaSigParser.parse(Class.forName(h, true, clazz.getClassLoader)) 38 | topSymbol.flatMap(_.symbols.collectFirst { case s: ClassSymbol if s.name == name => s }) 39 | } 40 | 41 | val sym = maybeSym.getOrElse { 42 | throw new IllegalArgumentException( 43 | "Unable to find class symbol through ScalaSigParser for class %s." 44 | .format(clazz.getName)) 45 | } 46 | 47 | sym.children.iterator 48 | .collect { case m: MethodSymbol if m.isCaseAccessor && !m.isPrivate => m } 49 | .zipWithIndex 50 | .map { case (ms, idx) => 51 | val defaultValue = 52 | try Some(companionClass.getMethod("apply$default$" + (idx + 1)).invoke(companionObject)) 53 | catch { 54 | case _: NoSuchMethodException => None 55 | } 56 | CaseClassFieldMeta(ms.name, defaultValue) 57 | } 58 | .toIndexedSeq 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /util/src/main/scala/ValidatedFlatMap.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import cats.data.Validated 4 | 5 | class ValidatedFlatMap[E, A](val v: Validated[E, A]) extends AnyVal { 6 | def flatMap[EE >: E, B](f: A => Validated[EE, B]): Validated[EE, B] = 7 | v.andThen(f) 8 | } 9 | 10 | /** Cats [[Validated]] does not provide `flatMap` because its purpose is to accumulate errors. 11 | * 12 | * To combine [[Validated]] in for-comprehension, it is possible to import this implicit conversion 13 | * - with the knowledge that the `flatMap` short-circuits errors. 14 | * http://typelevel.org/cats/datatypes/validated.html 15 | */ 16 | object ValidatedFlatMapFeature { 17 | import scala.language.implicitConversions 18 | 19 | @inline implicit def ValidationFlatMapRequested[E, A]( 20 | d: Validated[E, A]): ValidatedFlatMap[E, A] = 21 | new ValidatedFlatMap(d) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /util/src/test/scala/DateTimeFormatsRoundtripSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import org.scalacheck.Properties 4 | import java.time.ZonedDateTime 5 | import org.scalacheck.Gen 6 | import java.time.LocalDateTime 7 | import java.time.ZoneOffset 8 | import org.scalacheck.Arbitrary 9 | import java.time.OffsetDateTime 10 | import java.time.Instant 11 | import java.time.LocalTime 12 | import org.scalacheck.Prop 13 | import org.joda.time.{ 14 | DateTime => JodaDateTime, 15 | LocalDate => JodaLocalDate, 16 | LocalTime => JodaLocalTime 17 | } 18 | import org.joda.time.{DateTimeZone => JodaDateTimeZone} 19 | import org.joda.time.format.ISODateTimeFormat 20 | import java.time.LocalDate 21 | import java.time.temporal.ChronoField 22 | 23 | class DateTimeFormatsRoundtripSpec extends Properties("DateTimeFormats roundtrip") { 24 | val epochMillis = Gen.choose( 25 | ZonedDateTime 26 | .of(LocalDateTime.of(-10000, 1, 1, 0, 0, 0), ZoneOffset.UTC) 27 | .toInstant() 28 | .toEpochMilli(), 29 | ZonedDateTime 30 | .of(LocalDateTime.of(+10000, 12, 31, 23, 59, 59), ZoneOffset.UTC) 31 | .toInstant() 32 | .toEpochMilli() 33 | ) 34 | 35 | val zoneOffset = Gen.choose(-10, +10).map(ZoneOffset.ofHours) 36 | 37 | implicit def arbitraryDateTime: Arbitrary[OffsetDateTime] = 38 | Arbitrary( 39 | epochMillis.map(ts => OffsetDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC))) 40 | 41 | implicit val arbitraryInstant: Arbitrary[Instant] = 42 | Arbitrary(epochMillis.map(Instant.ofEpochMilli)) 43 | 44 | implicit val arbitratyLocalTime: Arbitrary[LocalTime] = Arbitrary( 45 | Gen.choose(0, 3600 * 24 - 1).map(LocalTime.ofSecondOfDay(_)) 46 | ) 47 | 48 | implicit val arbitratyLocalDate: Arbitrary[LocalDate] = Arbitrary( 49 | epochMillis 50 | .map(Instant.ofEpochMilli(_).atZone(ZoneOffset.UTC)) 51 | .map(_.toLocalDate()) 52 | ) 53 | 54 | def jodaFormat(dateTime: JodaDateTime): String = 55 | ISODateTimeFormat.dateTime.print(dateTime.withZone(JodaDateTimeZone.UTC)) 56 | 57 | def jodaFormat(time: JodaLocalTime): String = 58 | ISODateTimeFormat.time.print(time) 59 | 60 | def jodaFormat(date: JodaLocalDate): String = 61 | ISODateTimeFormat.date.print(date) 62 | 63 | property("compatibility between serialized OffsetDateTime and joda.time.DateTime") = Prop.forAll { 64 | (instant: Instant) => 65 | val javaDateTime = instant.atOffset(ZoneOffset.UTC) 66 | val jodaDateTime = new JodaDateTime(instant.toEpochMilli(), JodaDateTimeZone.UTC) 67 | 68 | val serializedJava = DateTimeFormats.format(javaDateTime) 69 | val serializedJoda = jodaFormat(jodaDateTime) 70 | val res = serializedJava == serializedJoda 71 | if (!res) { 72 | println("------") 73 | println(serializedJava) 74 | println(serializedJoda) 75 | } 76 | res 77 | } 78 | 79 | property("compatibility between serialized java.time.LocalTime and org.joda.time.LocalTime") = 80 | Prop.forAll { (javaTime: LocalTime) => 81 | val jodaTime = JodaLocalTime.fromMillisOfDay(javaTime.toNanoOfDay() / 1000000) 82 | 83 | val serializedJava = DateTimeFormats.format(javaTime) 84 | val serializedJoda = jodaFormat(jodaTime) 85 | serializedJava == serializedJoda 86 | } 87 | 88 | property("compatibility between serialized java.time.LocalDate and org.joda.time.LocalDate") = 89 | Prop.forAll { (javaDate: LocalDate) => 90 | val jodaDate = 91 | new JodaLocalDate(javaDate.getYear(), javaDate.getMonthValue(), javaDate.getDayOfMonth()) 92 | 93 | val serializedJava = DateTimeFormats.format(javaDate) 94 | val serializedJoda = jodaFormat(jodaDate) 95 | serializedJava == serializedJoda 96 | } 97 | 98 | property("roundtrip from java.time.OffsetDateTime") = Prop.forAll { 99 | (instant: Instant, offset: ZoneOffset) => 100 | val source = instant.atOffset(offset) 101 | val serialized = DateTimeFormats.format(source) 102 | val deserialized = DateTimeFormats.parseOffsetDateTime(serialized) 103 | 104 | deserialized.fold(_ => false, _.toInstant() == source.toInstant()) 105 | } 106 | 107 | property("roundtrip from java.time.Instant") = Prop.forAll { (instant: Instant) => 108 | val serialized = DateTimeFormats.format(instant) 109 | val deserialized = DateTimeFormats.parseInstant(serialized) 110 | 111 | deserialized.fold(_ => false, _ == instant) 112 | } 113 | 114 | property("roundtrip from java.time.LocalDateTime") = Prop.forAll { (instant: Instant) => 115 | val source = instant.atOffset(ZoneOffset.UTC).toLocalDateTime() 116 | val serialized = DateTimeFormats.format(source) 117 | val deserialized = DateTimeFormats.parseLocalDateTime(serialized) 118 | 119 | deserialized.fold(_ => false, _ == source) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /util/src/test/scala/DomainObjectsGen.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import java.util.Currency 4 | 5 | import org.scalacheck.Gen 6 | 7 | import scala.collection.JavaConverters._ 8 | 9 | object DomainObjectsGen { 10 | 11 | private val currency: Gen[Currency] = 12 | Gen.oneOf(Currency.getAvailableCurrencies.asScala.toSeq) 13 | 14 | val money: Gen[Money] = for { 15 | currency <- currency 16 | amount <- Gen.chooseNum[Long](Long.MinValue, Long.MaxValue) 17 | } yield Money(amount, currency) 18 | 19 | val highPrecisionMoney: Gen[HighPrecisionMoney] = for { 20 | money <- money 21 | } yield HighPrecisionMoney.fromMoney(money, money.currency.getDefaultFractionDigits) 22 | 23 | val baseMoney: Gen[BaseMoney] = Gen.oneOf(money, highPrecisionMoney) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /util/src/test/scala/HighPrecisionMoneySpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import java.util.Currency 4 | import cats.data.Validated.Invalid 5 | import io.sphere.util.HighPrecisionMoney.ImplicitsDecimalPrecise.HighPrecisionPreciseMoneyNotation 6 | import org.scalatest.funspec.AnyFunSpec 7 | import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks 8 | import org.scalatest.matchers.must.Matchers 9 | 10 | import scala.collection.mutable.ArrayBuffer 11 | import scala.language.postfixOps 12 | 13 | class HighPrecisionMoneySpec extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { 14 | import HighPrecisionMoney.ImplicitsString._ 15 | import HighPrecisionMoney.ImplicitsStringPrecise._ 16 | 17 | implicit val defaultRoundingMode: BigDecimal.RoundingMode.Value = 18 | BigDecimal.RoundingMode.HALF_EVEN 19 | 20 | val Euro: Currency = Currency.getInstance("EUR") 21 | 22 | describe("High Precision Money") { 23 | 24 | it("should allow creation of high precision money") { 25 | ("0.01".EUR) must equal("0.01".EUR) 26 | } 27 | 28 | it( 29 | "should not allow creation of high precision money with less fraction digits than the currency has") { 30 | val thrown = intercept[IllegalArgumentException] { 31 | "0.01".EUR_PRECISE(1) 32 | } 33 | 34 | assert( 35 | thrown.getMessage == "requirement failed: `fractionDigits` should be >= than the default fraction digits of the currency.") 36 | } 37 | 38 | it("should convert precise amount to long value correctly") { 39 | "0.0001".EUR_PRECISE(4).preciseAmount must equal(1) 40 | } 41 | 42 | it("should reduce fraction digits as expected") { 43 | "0.0001".EUR_PRECISE(4).withFractionDigits(2).preciseAmount must equal(0) 44 | } 45 | 46 | it("should support the unary '-' operator.") { 47 | -"0.01".EUR_PRECISE(2) must equal("-0.01".EUR_PRECISE(2)) 48 | } 49 | 50 | it("should throw error on overflow in the unary '-' operator.") { 51 | a[MoneyOverflowException] must be thrownBy { 52 | -(BigDecimal(Long.MinValue) / 1000).EUR_PRECISE(3) 53 | } 54 | } 55 | 56 | it("should support the binary '+' operator.") { 57 | ("0.001".EUR_PRECISE(3)) + ("0.002".EUR_PRECISE(3)) must equal( 58 | "0.003".EUR_PRECISE(3) 59 | ) 60 | 61 | ("0.005".EUR_PRECISE(3)) + Money.fromDecimalAmount(BigDecimal("0.01"), Euro) must equal( 62 | "0.015".EUR_PRECISE(3) 63 | ) 64 | 65 | ("0.005".EUR_PRECISE(3)) + BigDecimal("0.005") must equal( 66 | "0.010".EUR_PRECISE(3) 67 | ) 68 | } 69 | 70 | it("should throw error on overflow in the binary '+' operator.") { 71 | a[MoneyOverflowException] must be thrownBy { 72 | (BigDecimal(Long.MaxValue) / 1000).EUR_PRECISE(3) + 1 73 | } 74 | } 75 | 76 | it("should support the binary '-' operator.") { 77 | ("0.002".EUR_PRECISE(3)) - ("0.001".EUR_PRECISE(3)) must equal( 78 | "0.001".EUR_PRECISE(3) 79 | ) 80 | 81 | ("0.015".EUR_PRECISE(3)) - Money.fromDecimalAmount(BigDecimal("0.01"), Euro) must equal( 82 | "0.005".EUR_PRECISE(3) 83 | ) 84 | 85 | ("0.005".EUR_PRECISE(3)) - BigDecimal("0.005") must equal( 86 | "0.000".EUR_PRECISE(3) 87 | ) 88 | } 89 | 90 | it("should throw error on overflow in the binary '-' operator.") { 91 | a[MoneyOverflowException] must be thrownBy { 92 | (BigDecimal(Long.MinValue) / 1000).EUR_PRECISE(3) - 1 93 | } 94 | } 95 | 96 | it("should support the binary '*' operator.") { 97 | ("0.002".EUR_PRECISE(3)) * ("5.00".EUR_PRECISE(2)) must equal( 98 | "0.010".EUR_PRECISE(3) 99 | ) 100 | 101 | ("0.015".EUR_PRECISE(3)) * Money.fromDecimalAmount(BigDecimal("100.00"), Euro) must equal( 102 | "1.500".EUR_PRECISE(3) 103 | ) 104 | 105 | ("0.005".EUR_PRECISE(3)) * BigDecimal("0.005") must equal( 106 | "0.000".EUR_PRECISE(3) 107 | ) 108 | } 109 | 110 | it("should throw error on overflow in the binary '*' operator.") { 111 | a[MoneyOverflowException] must be thrownBy { 112 | (BigDecimal(Long.MaxValue / 1000) / 2 + 1).EUR_PRECISE(3) * 2 113 | } 114 | } 115 | 116 | it("should support the binary '%' operator.") { 117 | ("0.010".EUR_PRECISE(3)) % ("5.00".EUR_PRECISE(2)) must equal( 118 | "0.010".EUR_PRECISE(3) 119 | ) 120 | 121 | ("100.000".EUR_PRECISE(3)) % Money.fromDecimalAmount(BigDecimal("100.00"), Euro) must equal( 122 | "0.000".EUR_PRECISE(3) 123 | ) 124 | 125 | ("0.015".EUR_PRECISE(3)) % BigDecimal("0.002") must equal( 126 | "0.001".EUR_PRECISE(3) 127 | ) 128 | } 129 | 130 | it("should throw error on overflow in the binary '%' operator.") { 131 | noException must be thrownBy { 132 | BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3) % 0.5 133 | } 134 | } 135 | 136 | it("should support the binary '/%' operator.") { 137 | "10.000".EUR_PRECISE(3)./%(3.00) must equal( 138 | ("3.000".EUR_PRECISE(3), "1.000".EUR_PRECISE(3)) 139 | ) 140 | } 141 | 142 | it("should throw error on overflow in the binary '/%' operator.") { 143 | a[MoneyOverflowException] must be thrownBy { 144 | BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3) /% 0.5 145 | } 146 | } 147 | 148 | it("should support the remainder operator.") { 149 | "10.000".EUR_PRECISE(3).remainder(3.00) must equal("1.000".EUR_PRECISE(3)) 150 | 151 | "10.000".EUR_PRECISE(3).remainder("3.000".EUR_PRECISE(3)) must equal("1.000".EUR_PRECISE(3)) 152 | } 153 | 154 | it("should not overflow when getting the remainder of a division ('%').") { 155 | noException must be thrownBy { 156 | BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3).remainder(0.5) 157 | } 158 | } 159 | 160 | it("should partition the value properly.") { 161 | "10.000".EUR_PRECISE(3).partition(1, 2, 3) must equal( 162 | ArrayBuffer( 163 | "1.667".EUR_PRECISE(3), 164 | "3.333".EUR_PRECISE(3), 165 | "5.000".EUR_PRECISE(3) 166 | ) 167 | ) 168 | } 169 | 170 | it("should validate fractionDigits (min)") { 171 | val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 1, Euro, None) 172 | 173 | errors.toList must be( 174 | List("fractionDigits must be > 2 (default fraction digits defined by currency EUR).")) 175 | } 176 | 177 | it("should validate fractionDigits (max)") { 178 | val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 100, Euro, None) 179 | 180 | errors.toList must be(List("fractionDigits must be <= 20.")) 181 | } 182 | 183 | it("should validate centAmount") { 184 | val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 4, Euro, Some(1)) 185 | 186 | errors.toList must be( 187 | List( 188 | "centAmount must be correctly rounded preciseAmount (a number between 1234 and 1235).")) 189 | } 190 | 191 | it("should provide convenient toString") { 192 | "10.000".EUR_PRECISE(3).toString must be("10.000 EUR") 193 | "0.100".EUR_PRECISE(3).toString must be("0.100 EUR") 194 | "0.010".EUR_PRECISE(3).toString must be("0.010 EUR") 195 | "0.000".EUR_PRECISE(3).toString must be("0.000 EUR") 196 | "94.500".EUR_PRECISE(3).toString must be("94.500 EUR") 197 | "94".JPY_PRECISE(0).toString must be("94 JPY") 198 | } 199 | 200 | it("should not fail on toString") { 201 | forAll(DomainObjectsGen.highPrecisionMoney) { m => 202 | m.toString 203 | } 204 | } 205 | 206 | it("should fail on too big fraction decimal") { 207 | val thrown = intercept[IllegalArgumentException] { 208 | val tooManyDigits = Euro.getDefaultFractionDigits + 19 209 | HighPrecisionMoney.fromCentAmount(100003, tooManyDigits, Euro) 210 | } 211 | 212 | assert(thrown.getMessage == "Cannot represent number bigger than 10^19 with a Long") 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /util/src/test/scala/LangTagSpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.must.Matchers 5 | 6 | import scala.language.postfixOps 7 | 8 | class LangTagSpec extends AnyFunSpec with Matchers { 9 | describe("LangTag") { 10 | it("should accept valid language tags") { 11 | LangTag.unapply("de").isEmpty must be(false) 12 | LangTag.unapply("fr").isEmpty must be(false) 13 | LangTag.unapply("de-DE").isEmpty must be(false) 14 | LangTag.unapply("de-AT").isEmpty must be(false) 15 | LangTag.unapply("de-CH").isEmpty must be(false) 16 | LangTag.unapply("fr-FR").isEmpty must be(false) 17 | LangTag.unapply("fr-CA").isEmpty must be(false) 18 | LangTag.unapply("he-IL-u-ca-hebrew-tz-jeruslm").isEmpty must be(false) 19 | } 20 | 21 | it("should not accept invalid language tags") { 22 | LangTag.unapply(" de").isEmpty must be(true) 23 | LangTag.unapply("de_DE").isEmpty must be(true) 24 | LangTag.unapply("e-DE").isEmpty must be(true) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /util/src/test/scala/MoneySpec.scala: -------------------------------------------------------------------------------- 1 | package io.sphere.util 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.must.Matchers 5 | import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks 6 | 7 | import scala.language.postfixOps 8 | 9 | class MoneySpec extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { 10 | import Money.ImplicitsDecimal._ 11 | import Money._ 12 | 13 | implicit val mode: BigDecimal.RoundingMode.Value = BigDecimal.RoundingMode.UNNECESSARY 14 | 15 | def euroCents(cents: Long): Money = EUR(0).withCentAmount(cents) 16 | 17 | describe("Money") { 18 | it("should have value semantics.") { 19 | (1.23 EUR) must equal(1.23 EUR) 20 | } 21 | 22 | it( 23 | "should default to HALF_EVEN rounding mode when using monetary notation and use provided rounding mode when performing operations.") { 24 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 25 | 26 | (1.001 EUR) must equal(1.00 EUR) 27 | (1.005 EUR) must equal(1.00 EUR) 28 | (1.015 EUR) must equal(1.02 EUR) 29 | ((1.00 EUR) + 0.001) must equal(1.00 EUR) 30 | ((1.00 EUR) + 0.005) must equal(1.00 EUR) 31 | ((1.00 EUR) + 0.015) must equal(1.02 EUR) 32 | ((1.00 EUR) - 0.005) must equal(1.00 EUR) 33 | ((1.00 EUR) - 0.015) must equal(0.98 EUR) 34 | ((1.00 EUR) + 0.0115) must equal(1.01 EUR) 35 | } 36 | 37 | it( 38 | "should not accept an amount with an invalid scale for the used currency when using the constructor directly.") { 39 | an[IllegalArgumentException] must be thrownBy { 40 | Money(1.0001, java.util.Currency.getInstance("EUR")) 41 | } 42 | } 43 | 44 | it("should not be prone to common rounding errors known from floating point numbers.") { 45 | var m = 0.00 EUR 46 | 47 | for (i <- 1 to 10) m = m + 0.10 48 | 49 | m must equal(1.00 EUR) 50 | } 51 | 52 | it("should support the unary '-' operator.") { 53 | -EUR(1.00) must equal(-1.00 EUR) 54 | } 55 | 56 | it("should throw error on overflow in the unary '-' operator.") { 57 | a[MoneyOverflowException] must be thrownBy { 58 | -euroCents(Long.MinValue) 59 | } 60 | } 61 | 62 | it("should support the binary '+' operator.") { 63 | (1.42 EUR) + (1.58 EUR) must equal(3.00 EUR) 64 | } 65 | 66 | it("should support the binary '+' operator on different currencies.") { 67 | an[IllegalArgumentException] must be thrownBy { 68 | (1.42 EUR) + (1.58 USD) 69 | } 70 | } 71 | 72 | it("should throw error on overflow in the binary '+' operator.") { 73 | a[MoneyOverflowException] must be thrownBy { 74 | euroCents(Long.MaxValue) + 1 75 | } 76 | } 77 | 78 | it("should support the binary '-' operator.") { 79 | (1.33 EUR) - (0.33 EUR) must equal(1.00 EUR) 80 | } 81 | 82 | it("should throw error on overflow in the binary '-' operator.") { 83 | a[MoneyOverflowException] must be thrownBy { 84 | euroCents(Long.MinValue) - 1 85 | } 86 | } 87 | 88 | it("should support the binary '*' operator, requiring a rounding mode.") { 89 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 90 | (1.33 EUR) * (1.33 EUR) must equal(1.77 EUR) 91 | } 92 | 93 | it("should throw error on overflow in the binary '*' operator.") { 94 | a[MoneyOverflowException] must be thrownBy { 95 | euroCents(Long.MaxValue / 2 + 1) * 2 96 | } 97 | } 98 | 99 | it("should support the binary '/%' (divideAndRemainder) operator.") { 100 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 101 | (1.33 EUR) /% 0.3 must equal(4.00 EUR, 0.13 EUR) 102 | (1.33 EUR) /% 0.003 must equal(443.00 EUR, 0.00 EUR) 103 | } 104 | 105 | it("should throw error on overflow in the binary '/%' (divideAndRemainder) operator.") { 106 | a[MoneyOverflowException] must be thrownBy { 107 | euroCents(Long.MaxValue) /% 0.5 108 | } 109 | } 110 | 111 | it("should support getting the remainder of a division ('%').") { 112 | implicit val mode = BigDecimal.RoundingMode.HALF_EVEN 113 | (1.25 EUR).remainder(1.1) must equal(0.15 EUR) 114 | (1.25 EUR) % 1.1 must equal(0.15 EUR) 115 | } 116 | 117 | it("should not overflow when getting the remainder of a division ('%').") { 118 | noException must be thrownBy { 119 | euroCents(Long.MaxValue).remainder(0.5) 120 | } 121 | } 122 | 123 | it("should support partitioning an amount without losing or gaining money.") { 124 | (0.05 EUR).partition(3, 7) must equal(Seq(0.02 EUR, 0.03 EUR)) 125 | (10 EUR).partition(1, 2) must equal(Seq(3.34 EUR, 6.66 EUR)) 126 | (10 EUR).partition(3, 1, 3) must equal(Seq(4.29 EUR, 1.43 EUR, 4.28 EUR)) 127 | } 128 | 129 | it("should allow comparing money with the same currency.") { 130 | ((1.10 EUR) > (1.00 EUR)) must be(true) 131 | ((1.00 EUR) >= (1.00 EUR)) must be(true) 132 | ((1.00 EUR) < (1.10 EUR)) must be(true) 133 | ((1.00 EUR) <= (1.00 EUR)) must be(true) 134 | } 135 | 136 | it("should support currencies with a scale of 0 (i.e. Japanese Yen)") { 137 | (1 JPY) must equal(1 JPY) 138 | } 139 | 140 | it("should be able to update the centAmount") { 141 | (1.10 EUR).withCentAmount(170) must be(1.70 EUR) 142 | (1.10 EUR).withCentAmount(1711) must be(17.11 EUR) 143 | (1 JPY).withCentAmount(34) must be(34 JPY) 144 | } 145 | 146 | it("should provide convenient toString") { 147 | (1 JPY).toString must be("1 JPY") 148 | (1.00 EUR).toString must be("1.00 EUR") 149 | (0.10 EUR).toString must be("0.10 EUR") 150 | (0.01 EUR).toString must be("0.01 EUR") 151 | (0.00 EUR).toString must be("0.00 EUR") 152 | (94.5 EUR).toString must be("94.50 EUR") 153 | } 154 | 155 | it("should not fail on toString") { 156 | forAll(DomainObjectsGen.money) { m => 157 | m.toString 158 | } 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /util/src/test/scala/ScalaLoggingCompatiblitySpec.scala: -------------------------------------------------------------------------------- 1 | import com.typesafe.scalalogging.Logger 2 | import org.scalatest.funspec.AnyFunSpec 3 | import org.scalatest.matchers.must.Matchers 4 | 5 | class ScalaLoggingCompatiblitySpec extends AnyFunSpec with Matchers { 6 | 7 | describe("Ensure we skip ScalaLogging 3.9.5, because varargs will not compile under 3.9.5") { 8 | // Github issue about the bug: https://github.com/lightbend-labs/scala-logging/issues/354 9 | // This test can be removed if it compiles with scala-logging versions bigger than 3.9.5 10 | object Log extends com.typesafe.scalalogging.StrictLogging { 11 | val log: Logger = logger 12 | } 13 | val list: List[AnyRef] = List("log", "Some more") 14 | 15 | Log.log.warn("Message1", list: _*) 16 | } 17 | 18 | } 19 | --------------------------------------------------------------------------------