From 7d6fe2ff2a74c6e8f00d32b25bfd2b3c73633952 Mon Sep 17 00:00:00 2001 From: satorg Date: Sat, 12 Oct 2024 12:43:35 -0700 Subject: [PATCH 1/2] introduce `DerivedCirceKeyCodec` with descendants --- .../integrations/DerivedCirceKeyCodec.scala | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 integration-circe/all/shared/src/main/scala/monix/newtypes/integrations/DerivedCirceKeyCodec.scala diff --git a/integration-circe/all/shared/src/main/scala/monix/newtypes/integrations/DerivedCirceKeyCodec.scala b/integration-circe/all/shared/src/main/scala/monix/newtypes/integrations/DerivedCirceKeyCodec.scala new file mode 100644 index 0000000..f8e46da --- /dev/null +++ b/integration-circe/all/shared/src/main/scala/monix/newtypes/integrations/DerivedCirceKeyCodec.scala @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021-2024 Alexandru Nedelcu. + * See the project homepage at: https://newtypes.monix.io/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package monix.newtypes +package integrations + +import io.circe.{KeyEncoder, KeyDecoder} + +/** Derives a type-class instances for encoding and decoding JSON object keys + * for any type implementing [[HasExtractor]] and [[HasBuilder]] type-class + * instances. + * + * See + * [[https://circe.github.io/circe/codecs/custom-codecs.html#custom-key-types]]. + */ +trait DerivedCirceKeyCodec + extends DerivedCirceKeyDecoder + with DerivedCirceKeyEncoder + +/** Derives a `io.circe.KeyDecoder` type-class instance for decoding a JSON + * object key to any type with a [[HasBuilder]] instance. + * + * See + * [[https://circe.github.io/circe/codecs/custom-codecs.html#custom-key-types]]. + */ +trait DerivedCirceKeyDecoder { + implicit def jsonKeyDecoder[T, S](implicit + builder: HasBuilder.Aux[T, S], + dec: KeyDecoder[S] + ): KeyDecoder[T] = { + jsonKeyDecode(_) + } + + protected def jsonKeyDecode[T, S](s: String)(implicit + builder: HasBuilder.Aux[T, S], + dec: KeyDecoder[S] + ): Option[T] = { + dec.apply(s).flatMap { + builder.build(_).toOption + } + } +} + +/** Derives a `io.circe.KeyEncoder` type-class instance for encoding any type + * with a [[HasExtractor]] instance to a JSON object key. + * + * See + * [[https://circe.github.io/circe/codecs/custom-codecs.html#custom-key-types]]. + */ +trait DerivedCirceKeyEncoder { + implicit def jsonKeyEncoder[T, S](implicit + extractor: HasExtractor.Aux[T, S], + enc: KeyEncoder[S] + ): KeyEncoder[T] = { + jsonKeyEncode(_) + } + + protected def jsonKeyEncode[T, S](a: T)(implicit + extractor: HasExtractor.Aux[T, S], + enc: KeyEncoder[S] + ): String = { + enc.apply(extractor.extract(a)) + } +} From 7c89b8f40d79d57f979ec9386a136f33c92b97d8 Mon Sep 17 00:00:00 2001 From: satorg Date: Sat, 12 Oct 2024 15:06:05 -0700 Subject: [PATCH 2/2] add tests --- .../NewsubtypeCirceKeyCodecSuite.scala | 76 +++++++++++++++++++ .../NewtypeCirceKeyCodecSuite.scala | 76 +++++++++++++++++++ .../NewtypeKCirceKeyCodecSuite.scala | 69 +++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewsubtypeCirceKeyCodecSuite.scala create mode 100644 integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeCirceKeyCodecSuite.scala create mode 100644 integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeKCirceKeyCodecSuite.scala diff --git a/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewsubtypeCirceKeyCodecSuite.scala b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewsubtypeCirceKeyCodecSuite.scala new file mode 100644 index 0000000..2c3b4cd --- /dev/null +++ b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewsubtypeCirceKeyCodecSuite.scala @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021-2024 Alexandru Nedelcu. + * See the project homepage at: https://newtypes.monix.io/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package monix.newtypes +package integrations + +import io.circe.syntax._ +import org.scalatest.EitherValues +import org.scalatest.funsuite.AnyFunSuite + +class NewsubtypeCirceKeyCodecSuite extends AnyFunSuite with EitherValues { + import NewsubtypeCirceKeyCodecSuite._ + + test("NewsubtypeWrapped has JSON key codec") { + val originalMap = Map(23459 -> "") + val expectedMap = Map(Text("23459") -> "") + + // Check KeyEncoder. + val expectedJson = originalMap.asJson + val receivedJson = expectedMap.asJson + assert(receivedJson == expectedJson) + + // Check KeyDecoder. + val receivedMap = expectedJson.as[Map[Text, String]] + assert(receivedMap == Right(expectedMap)) + } + + test("NewsubtypeValidated has JSON key codec") { + val originalMap = Map(0x7f -> "") + val expectedMap = PosByte(0x7f).map(k => Map(k -> "")).value + + // Check KeyEncoder. + val expectedJson = originalMap.asJson + val receivedJson = expectedMap.asJson + assert(receivedJson == expectedJson) + + // Check KeyDecoder. + val receivedMap = expectedJson.as[Map[PosByte, String]] + assert(receivedMap == Right(expectedMap)) + } + + // NOTE: Circe does not allow custom error messages in KeyDecoder. + test("NewsubtypeValidated JSON key decoder does validation") { + val received = Map(0 -> "").asJson.as[Map[PosByte, String]] + assert(received.isLeft) + } +} + +object NewsubtypeCirceKeyCodecSuite { + type Text = Text.Type + object Text extends NewsubtypeWrapped[String] with DerivedCirceKeyCodec + + type PosByte = PosByte.Type + object PosByte extends NewsubtypeValidated[Byte] with DerivedCirceKeyCodec { + override def apply(value: Byte): Either[BuildFailure[Type], Type] = + Either.cond( + value > 0, + unsafe(value), + BuildFailure() // message is not used by KeyDecoder anyway + ) + } +} diff --git a/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeCirceKeyCodecSuite.scala b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeCirceKeyCodecSuite.scala new file mode 100644 index 0000000..6e9ab75 --- /dev/null +++ b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeCirceKeyCodecSuite.scala @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021-2024 Alexandru Nedelcu. + * See the project homepage at: https://newtypes.monix.io/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package monix.newtypes +package integrations + +import io.circe.syntax._ +import org.scalatest.EitherValues +import org.scalatest.funsuite.AnyFunSuite + +class NewtypeCirceKeyCodecSuite extends AnyFunSuite with EitherValues { + import NewtypeCirceKeyCodecSuite._ + + test("NewtypeWrapped has JSON key codec") { + val originalMap = Map(12347 -> "") + val expectedMap = Map(Text("12347") -> "") + + // Check KeyEncoder. + val expectedJson = originalMap.asJson + val receivedJson = expectedMap.asJson + assert(receivedJson == expectedJson) + + // Check KeyDecoder. + val receivedMap = expectedJson.as[Map[Text, String]] + assert(receivedMap == Right(expectedMap)) + } + + test("NewtypeValidated has JSON key codec") { + val originalMap = Map(0x7f -> "") + val expectedMap = PosByte(0x7f).map(k => Map(k -> "")).value + + // Check KeyEncoder. + val expectedJson = originalMap.asJson + val receivedJson = expectedMap.asJson + assert(receivedJson == expectedJson) + + // Check KeyDecoder. + val receivedMap = expectedJson.as[Map[PosByte, String]] + assert(receivedMap == Right(expectedMap)) + } + + // NOTE: Circe does not allow custom error messages in KeyDecoder. + test("NewtypeValidated JSON key decoder does validation") { + val received = Map(0 -> "").asJson.as[Map[PosByte, String]] + assert(received.isLeft) + } +} + +object NewtypeCirceKeyCodecSuite { + type Text = Text.Type + object Text extends NewtypeWrapped[String] with DerivedCirceKeyCodec + + type PosByte = PosByte.Type + object PosByte extends NewtypeValidated[Byte] with DerivedCirceKeyCodec { + override def apply(value: Byte): Either[BuildFailure[Type], Type] = + Either.cond( + value > 0, + unsafe(value), + BuildFailure() // message is not used by KeyDecoder anyway + ) + } +} diff --git a/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeKCirceKeyCodecSuite.scala b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeKCirceKeyCodecSuite.scala new file mode 100644 index 0000000..d096302 --- /dev/null +++ b/integration-circe/all/shared/src/test/scala/monix/newtypes/integrations/NewtypeKCirceKeyCodecSuite.scala @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021-2024 Alexandru Nedelcu. + * See the project homepage at: https://newtypes.monix.io/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package monix.newtypes +package integrations + +import cats.Id +import io.circe.syntax._ +import org.scalatest.funsuite.AnyFunSuite + +class NewtypeKCirceKeyCodecSuite extends AnyFunSuite { + import NewtypeKCirceKeyCodecSuite._ + + test("NewtypeK has JSON key codec") { + val expectedI = Map(NewId(12358) -> "") + val expectedS = Map(NewId("12358") -> "") + + val jsonI = expectedI.asJson + val jsonS = expectedS.asJson + + val receivedI2I = jsonI.as[Map[NewId[Int], String]] + assert(receivedI2I == Right(expectedI)) + + val receivedI2S = jsonI.as[Map[NewId[String], String]] + assert(receivedI2S == Right(expectedS)) + + val receivedS2I = jsonS.as[Map[NewId[Int], String]] + assert(receivedS2I == Right(expectedI)) + + val obtainedS2S = jsonS.as[Map[NewId[String], String]] + assert(obtainedS2S == Right(expectedS)) + } + + // NOTE: Circe does not allow custom error messages in KeyDecoder. + test("NewtypeK JSON key codec does validation") { + val map = Map(NewId("ABCDEFG") -> "") + val json = map.asJson + val received = json.as[Map[NewId[Int], String]] + assert(received.isLeft) + } +} + +object NewtypeKCirceKeyCodecSuite { + type NewId[A] = NewId.Type[A] + + object NewId extends NewtypeK[Id] with DerivedCirceKeyCodec { + def apply[A](a: A): NewId[A] = unsafeCoerce(a) + + implicit def builder[A]: HasBuilder.Aux[Type[A], A] = + new HasBuilder[Type[A]] { + type Source = A + def build(value: A) = Right(apply(value)) + } + } +}