diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01458c7..c8d71b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.12.16, 2.13.8, 3.1.3] - java: [temurin@8] + scala: [2.12.17, 2.13.8, 3.2.0] + java: [temurin@11] project: [rootJS, rootJVM, rootNative] runs-on: ${{ matrix.os }} steps: @@ -38,21 +38,21 @@ jobs: with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' + - name: Download Java (temurin@11) + id: download-java-temurin-11 + if: matrix.java == 'temurin@11' uses: typelevel/download-java@v1 with: distribution: temurin - java-version: 8 + java-version: 11 - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' + - name: Setup Java (temurin@11) + if: matrix.java == 'temurin@11' uses: actions/setup-java@v2 with: distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + java-version: 11 + jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} - name: Cache sbt uses: actions/cache@v2 @@ -70,7 +70,7 @@ jobs: run: sbt 'project ${{ matrix.project }}' '++${{ matrix.scala }}' 'project /' githubWorkflowCheck - name: Check headers and formatting - if: matrix.java == 'temurin@8' + if: matrix.java == 'temurin@11' run: sbt 'project ${{ matrix.project }}' '++${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck - name: scalaJSLink @@ -85,20 +85,20 @@ jobs: run: sbt 'project ${{ matrix.project }}' '++${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'temurin@8' + if: matrix.java == 'temurin@11' run: sbt 'project ${{ matrix.project }}' '++${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'temurin@8' + if: matrix.java == 'temurin@11' run: sbt 'project ${{ matrix.project }}' '++${{ matrix.scala }}' doc - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p circe/.jvm/target target polyline/js/target .js/target core/.native/target core/.js/target circe/.js/target core/.jvm/target .jvm/target .native/target polyline/jvm/target polyline/native/target project/target + run: mkdir -p jsoniter-scala/.native/target circe/.jvm/target target polyline/js/target .js/target core/.native/target core/.js/target circe/.js/target core/.jvm/target .jvm/target .native/target polyline/jvm/target circe/.native/target jsoniter-scala/.jvm/target polyline/native/target jsoniter-scala/.js/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar circe/.jvm/target target polyline/js/target .js/target core/.native/target core/.js/target circe/.js/target core/.jvm/target .jvm/target .native/target polyline/jvm/target polyline/native/target project/target + run: tar cf targets.tar jsoniter-scala/.native/target circe/.jvm/target target polyline/js/target .js/target core/.native/target core/.js/target circe/.js/target core/.jvm/target .jvm/target .native/target polyline/jvm/target circe/.native/target jsoniter-scala/.jvm/target polyline/native/target jsoniter-scala/.js/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -115,7 +115,7 @@ jobs: matrix: os: [ubuntu-latest] scala: [2.13.8] - java: [temurin@8] + java: [temurin@11] runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) @@ -123,21 +123,21 @@ jobs: with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' + - name: Download Java (temurin@11) + id: download-java-temurin-11 + if: matrix.java == 'temurin@11' uses: typelevel/download-java@v1 with: distribution: temurin - java-version: 8 + java-version: 11 - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' + - name: Setup Java (temurin@11) + if: matrix.java == 'temurin@11' uses: actions/setup-java@v2 with: distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + java-version: 11 + jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} - name: Cache sbt uses: actions/cache@v2 @@ -151,32 +151,32 @@ jobs: ~/Library/Caches/Coursier/v1 key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - name: Download target directories (2.12.16, rootJS) + - name: Download target directories (2.12.17, rootJS) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.16-rootJS + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-rootJS - - name: Inflate target directories (2.12.16, rootJS) + - name: Inflate target directories (2.12.17, rootJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.16, rootJVM) + - name: Download target directories (2.12.17, rootJVM) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.16-rootJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-rootJVM - - name: Inflate target directories (2.12.16, rootJVM) + - name: Inflate target directories (2.12.17, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.16, rootNative) + - name: Download target directories (2.12.17, rootNative) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.16-rootNative + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.17-rootNative - - name: Inflate target directories (2.12.16, rootNative) + - name: Inflate target directories (2.12.17, rootNative) run: | tar xf targets.tar rm targets.tar @@ -211,32 +211,32 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (3.1.3, rootJS) + - name: Download target directories (3.2.0, rootJS) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.3-rootJS + name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.0-rootJS - - name: Inflate target directories (3.1.3, rootJS) + - name: Inflate target directories (3.2.0, rootJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.1.3, rootJVM) + - name: Download target directories (3.2.0, rootJVM) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.3-rootJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.0-rootJVM - - name: Inflate target directories (3.1.3, rootJVM) + - name: Inflate target directories (3.2.0, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.1.3, rootNative) + - name: Download target directories (3.2.0, rootNative) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.3-rootNative + name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.0-rootNative - - name: Inflate target directories (3.1.3, rootNative) + - name: Inflate target directories (3.2.0, rootNative) run: | tar xf targets.tar rm targets.tar diff --git a/README.md b/README.md index fec5ded..b2a3533 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.gnieh/geo-scala-core_2.13/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.gnieh/geo-scala-core_2.13) [![Continuous Integration](https://github.com/gnieh/geo-scala/actions/workflows/ci.yml/badge.svg)](https://github.com/gnieh/geo-scala/actions/workflows/ci.yml) -A core AST and utilities for GeoJSON ([RFC 7946][rfc-7946]) and more. Builds for Scala 3, 2.13 and 2.12 on JVM, JS and partially Scala Native. +A core AST and utilities for GeoJSON ([RFC 7946][rfc-7946]) and more. Builds for Scala 3, 2.13, and 2.12 on JVM, JS, and Scala Native. The project is divided in several submodules: - `core` contains the data model for geographical entities; - `circe` contains a set of [circe][circe] encoders and decoders for GeoJSON data model; + - `jsoniter-scala` contains a set of [jsoniter-scala][jsoniter-scala] codecs for GeoJSON data model; - `polyline` contains utilities to convert GeoJSON line strings to and from [polylines][polyline]. ## Quickstart @@ -42,4 +43,5 @@ Unless required by applicable law or agreed to in writing, software distributed [rfc-7946]: https://tools.ietf.org/html/rfc7946 [circe]: https://circe.github.io/circe +[jsoniter-scala]: https://github.com/plokhotnyuk/jsoniter-scala [polyline]: https://developers.google.com/maps/documentation/utilities/polylineutility diff --git a/build.sbt b/build.sbt index 5c0073f..cc77a66 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,8 @@ -val scala212 = "2.12.16" +val scala212 = "2.12.17" val scala213 = "2.13.8" -val scala3 = "3.1.3" +val scala3 = "3.2.0" -ThisBuild / tlBaseVersion := "0.2" +ThisBuild / tlBaseVersion := "0.4" ThisBuild / organization := "org.gnieh" ThisBuild / organizationName := "GHM Mobile Development GmbH" @@ -16,18 +16,17 @@ ThisBuild / tlSonatypeUseLegacyHost := true ThisBuild / crossScalaVersions := Seq(scala212, scala213, scala3) ThisBuild / scalaVersion := scala213 // the default Scala +ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("11")) +ThisBuild / tlJdkRelease := Some(8) -lazy val root = tlCrossRootProject.aggregate(core, circe, polyline).settings(name := "geo-scala") +lazy val root = tlCrossRootProject.aggregate(core, circe, jsoniterScala, polyline).settings(name := "geo-scala") lazy val commonSettings = Seq( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % "3.2.12" % Test, + "org.scalatest" %%% "scalatest" % "3.2.13" % Test, "org.scalatestplus" %%% "scalacheck-1-16" % "3.2.13.0" % Test, "org.scalacheck" %%% "scalacheck" % "1.16.0" % Test - ), - // disable MiMa until we have proper version released on gnieh / all platforms - // once removed, the tlBaseVersion has to adjusted as well - mimaPreviousArtifacts := Set.empty + ) ) lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) @@ -39,16 +38,33 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) ) -val circeVersion = "0.14.2" -lazy val circe = crossProject(JVMPlatform, JSPlatform) +val circeVersion = "0.14.3" +lazy val circe = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) .in(file("circe")) .dependsOn(core) .settings( commonSettings ++ Seq( name := "geo-scala-circe", - libraryDependencies += "io.circe" %%% "circe-core" % circeVersion, - libraryDependencies += "io.circe" %%% "circe-parser" % circeVersion % Test + libraryDependencies ++= Seq( + "io.circe" %%% "circe-core" % circeVersion, + "io.circe" %%% "circe-parser" % circeVersion % Test + ) + ) + ) + +val jsoniterScalaVersion = "2.17.4" +lazy val jsoniterScala = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("jsoniter-scala")) + .dependsOn(core) + .settings( + commonSettings ++ Seq( + name := "geo-scala-jsoniter-scala", + libraryDependencies ++= Seq( + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % jsoniterScalaVersion, + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % jsoniterScalaVersion % Provided + ) ) ) diff --git a/circe/src/main/scala/com/free2move/geoscala/circe.scala b/circe/src/main/scala/com/free2move/geoscala/circe.scala index 2608c0c..677a331 100644 --- a/circe/src/main/scala/com/free2move/geoscala/circe.scala +++ b/circe/src/main/scala/com/free2move/geoscala/circe.scala @@ -17,19 +17,18 @@ package com.free2move.geoscala import cats.syntax.functor._ - import io.circe._ import io.circe.syntax._ trait LowPriorityGeoJsonEncoders { - - implicit val coordinateEncoder: Encoder[Coordinate] = Encoder.instance { coord => + implicit val coordinateEncoder: Encoder[Coordinate] = Encoder.instance { (coord: Coordinate) => Json.arr(Json.fromDoubleOrNull(coord.longitude), Json.fromDoubleOrNull(coord.latitude)) } - private def makeGeometryEncoder[C: Encoder, G <: Geometry](`type`: String, coords: G => C): Encoder[G] = Encoder.instance { geometry => - Json.obj("type" := `type`, "coordinates" := coords(geometry)) - } + private def makeGeometryEncoder[C: Encoder, G <: Geometry](`type`: String, coords: G => C): Encoder[G] = + Encoder.instance { (geometry: G) => + Json.obj("type" := `type`, "coordinates" := coords(geometry)) + } implicit val pointEncoder: Encoder[Point] = makeGeometryEncoder("Point", _.coordinates) @@ -55,23 +54,24 @@ trait GeoJsonEncoders extends LowPriorityGeoJsonEncoders { case mp: MultiPolygon => mp.asJson } - implicit def extendedFeatureEncoder[Properties: Encoder.AsObject]: Encoder[Feature[Properties]] = Encoder.instance { feature => - Json.obj("type" := "Feature", "properties" := feature.properties, "geometry" := feature.geometry) - } + implicit def extendedFeatureEncoder[Properties: Encoder]: Encoder[Feature[Properties]] = + Encoder.instance { (feature: Feature[Properties]) => + Json.obj("type" := "Feature", "properties" := feature.properties, "geometry" := feature.geometry) + } - implicit def extendedFeatureCollectionEncoder[Properties: Encoder.AsObject]: Encoder[FeatureCollection[Properties]] = Encoder.instance { featureCollection => - Json.obj("type" := "FeatureCollection", "features" := featureCollection.features) - } + implicit def extendedFeatureCollectionEncoder[Properties: Encoder]: Encoder[FeatureCollection[Properties]] = + Encoder.instance { (featureCollection: FeatureCollection[Properties]) => + Json.obj("type" := "FeatureCollection", "features" := featureCollection.features) + } - implicit def geojsonEncoder[Properties: Encoder.AsObject]: Encoder[GeoJson[Properties]] = Encoder.instance { - case fc @ FeatureCollection(_) => fc.asJson - case f @ Feature(_, _) => f.asJson - case geom: Geometry => (geom: Geometry).asJson + implicit def geojsonEncoder[Properties: Encoder]: Encoder[GeoJson[Properties]] = Encoder.instance { + case fc: FeatureCollection[Properties] => fc.asJson + case f: Feature[Properties] => f.asJson + case geom: Geometry => (geom: Geometry).asJson } } trait GeoJsonDecoders { - implicit val coordinateDecoder: Decoder[Coordinate] = Decoder.instance { cursor => for { lng <- cursor.downN(0).as[Double] @@ -80,12 +80,13 @@ trait GeoJsonDecoders { } @inline - private def makeGeometryDecoder[C: Decoder, G <: Geometry](`type`: String, create: C => G): Decoder[G] = Decoder.instance[G] { cursor => - for { - _ <- ensureType(cursor, `type`) - coords <- cursor.downField("coordinates").as[C] - } yield create(coords) - } + private def makeGeometryDecoder[C: Decoder, G <: Geometry](`type`: String, create: C => G): Decoder[G] = + Decoder.instance[G] { (cursor: HCursor) => + for { + _ <- ensureType(cursor, `type`) + coords <- cursor.downField("coordinates").as[C] + } yield create(coords) + } implicit val pointDecoder: Decoder[Point] = makeGeometryDecoder("Point", Point.apply) @@ -115,31 +116,31 @@ trait GeoJsonDecoders { geometryDecoder.widen[GeoJson[Nothing]].asInstanceOf[Decoder[GeoJson[Properties]]] ).reduce(_ or _) - implicit def extendedFeatureDecoder[Properties: Decoder]: Decoder[Feature[Properties]] = Decoder.instance { cursor => - for { - _ <- ensureType(cursor, "Feature") - properties <- cursor.downField("properties").as[Properties] - geometry <- cursor.downField("geometry").as[Geometry] - } yield Feature(properties, geometry) - } + implicit def extendedFeatureDecoder[Properties: Decoder]: Decoder[Feature[Properties]] = + Decoder.instance { (cursor: HCursor) => + for { + _ <- ensureType(cursor, "Feature") + properties <- cursor.downField("properties").as[Properties] + geometry <- cursor.downField("geometry").as[Geometry] + } yield Feature(properties, geometry) + } - implicit def extendedFeatureCollectionDecoder[Properties: Decoder]: Decoder[FeatureCollection[Properties]] = Decoder.instance { cursor => - for { - _ <- ensureType(cursor, "FeatureCollection") - features <- cursor.downField("features").as[List[Feature[Properties]]] - } yield FeatureCollection(features) - } + implicit def extendedFeatureCollectionDecoder[Properties: Decoder]: Decoder[FeatureCollection[Properties]] = + Decoder.instance { (cursor: HCursor) => + for { + _ <- ensureType(cursor, "FeatureCollection") + features <- cursor.downField("features").as[List[Feature[Properties]]] + } yield FeatureCollection(features) + } @inline - private def ensureType(cursor: HCursor, `type`: String): Decoder.Result[String] = { + private def ensureType(cursor: HCursor, tpe: String): Decoder.Result[String] = { val typeCursor = cursor.downField("type") typeCursor.as[String] match { - case Right(b) if b != `type` => Left(DecodingFailure(s"GeoJSON's type is not ${`type`}", typeCursor.history)) - case res => res + case Right(b) if b != tpe => Left(DecodingFailure(s"GeoJSON's type is not $tpe", typeCursor.history)) + case res => res } - } - } /** Object with implicit circe encoders and decoders. You can alternatively mixin [[GeoJsonEncoders]] and [[GeoJsonDecoders]] instead of importing. diff --git a/circe/src/test/scala/com/free2move/geoscala/CirceDecodingAndEncodingTests.scala b/circe/src/test/scala/com/free2move/geoscala/CirceDecodingAndEncodingTests.scala new file mode 100644 index 0000000..fd3fcd6 --- /dev/null +++ b/circe/src/test/scala/com/free2move/geoscala/CirceDecodingAndEncodingTests.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2019 GHM Mobile Development GmbH + * + * 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 com.free2move.geoscala + +import com.free2move.geoscala.circe._ +import io.circe._ +import io.circe.syntax._ +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class CirceDecodingAndEncodingTests extends AnyFlatSpec with Matchers with EitherValues { + "The circe decoders" should "parse and serialize simple 2D points" in { + val json = """{"type":"Point","coordinates":[12.3046875,51.8357775]}""" + val obj = Point(Coordinate(12.3046875, 51.8357775)) + parser.decode[Point](json) shouldBe Right(obj) + parser.decode[Geometry](json) shouldBe Right(obj) + Printer.noSpaces.print(obj.asJson) shouldBe json + } + + it should "parse points with more dimensions as 2D points" in { + val json = """{"type":"Point","coordinates":[12.3046875,51.8357775,7.000,42.12345]}""" + val obj = Point(Coordinate(12.3046875, 51.8357775)) + parser.decode[Point](json) shouldBe Right(obj) + parser.decode[Geometry](json) shouldBe Right(obj) + } + + it should "parse and serialize FeatureCollection using Json encoder and decoder" in { + val json = + """{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":7},"geometry":{"type":"Point","coordinates":[12.3046875,51.8357775]}}]}""" + val obj = FeatureCollection(List(Feature(Json.obj("id" := 7), Point(Coordinate(12.3046875, 51.8357775))))) + parser.decode[FeatureCollection[Json]](json) shouldBe Right(obj) + parser.decode[GeoJson[Json]](json) shouldBe Right(obj) + Printer.noSpaces.print(obj.asJson) shouldBe json + } +} diff --git a/circe/src/test/scala/com/free2move/geoscala/CirceDecodingTests.scala b/circe/src/test/scala/com/free2move/geoscala/CirceDecodingTests.scala deleted file mode 100644 index f674043..0000000 --- a/circe/src/test/scala/com/free2move/geoscala/CirceDecodingTests.scala +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2019 GHM Mobile Development GmbH - * - * 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 com.free2move.geoscala - -import io.circe._ -import io.circe.syntax._ -import org.scalatest.EitherValues -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class CirceDecodingTests extends AnyFlatSpec with Matchers with EitherValues { - - import com.free2move.geoscala.circe._ - - "The circe decoders" should "handle simple 2D points" in { - val json = - """{ - "type": "Point", - "coordinates": [ - 12.3046875, - 51.8357775 - ] - }""" - parser.decode[Point](json) shouldBe Right(Point(Coordinate(12.3046875, 51.8357775))) - parser.decode[Geometry](json) shouldBe Right(Point(Coordinate(12.3046875, 51.8357775))) - } - - it should "handle points with more dimensions" in { - val json = - """{ - "type": "Point", - "coordinates": [ - 12.3046875, - 51.8357775, - 7.000, - 42.12345 - ] - }""" - parser.decode[Point](json) shouldBe Right(Point(Coordinate(12.3046875, 51.8357775))) - parser.decode[Geometry](json) shouldBe Right(Point(Coordinate(12.3046875, 51.8357775))) - } - - it should "handle FeatureCollection without Properties as pure JSON correctly" in { - val json = - """{ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "id": 7 - }, - "geometry": { - "type": "Point", - "coordinates": [ - 12.3046875, - 51.8357775 - ] - } - } - ] - }""" - parser.decode[FeatureCollection[Json]](json) shouldBe Right( - FeatureCollection( - List(Feature(Json.obj("id" := 7), Point(Coordinate(12.3046875, 51.8357775)))) - ) - ) - } - -} diff --git a/jsoniter-scala/src/main/scala/com/free2move/geoscala/jsoniter_scala.scala b/jsoniter-scala/src/main/scala/com/free2move/geoscala/jsoniter_scala.scala new file mode 100644 index 0000000..a0e9f1e --- /dev/null +++ b/jsoniter-scala/src/main/scala/com/free2move/geoscala/jsoniter_scala.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2019 GHM Mobile Development GmbH + * + * 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 com.free2move.geoscala + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ +import scala.collection.mutable.ListBuffer + +object jsoniter_scala { + // Uncomment for printing of codecs generated by macros + // implicit val printCodec: CodecMakerConfig.PrintCodec = new CodecMakerConfig.PrintCodec {} + + implicit val coordinateCodec: JsonValueCodec[Coordinate] = + new JsonValueCodec[Coordinate] { + override def decodeValue(in: JsonReader, default: Coordinate): Coordinate = + if (in.isNextToken('[')) { + val lon = in.readDouble() + if (!in.isNextToken(',')) in.commaError() + val lat = in.readDouble() + while (in.isNextToken(',')) in.skip() + if (!in.isCurrentToken(']')) in.arrayEndOrCommaError() + new Coordinate(lon, lat) + } else in.readNullOrTokenError(default, '[') + + override def encodeValue(x: Coordinate, out: JsonWriter): Unit = { + out.writeArrayStart() + out.writeVal(x.longitude) + out.writeVal(x.latitude) + out.writeArrayEnd() + } + + override def nullValue: Coordinate = null + } + + implicit val listOfCoordinatesCodec: JsonValueCodec[List[Coordinate]] = JsonCodecMaker.make + + implicit val listOfListOfCoordinatesCodec: JsonValueCodec[List[List[Coordinate]]] = JsonCodecMaker.make + + implicit val listOfListOfListOfCoordinatesCodec: JsonValueCodec[List[List[List[Coordinate]]]] = JsonCodecMaker.make + + implicit val pointCodec: JsonValueCodec[Point] = + makeGeometryCodec("Point", _.coordinates, Point.apply) + + implicit val multiPointCodec: JsonValueCodec[MultiPoint] = + makeGeometryCodec("MultiPoint", _.coordinates, MultiPoint.apply) + + implicit val lineStringCodec: JsonValueCodec[LineString] = + makeGeometryCodec("LineString", _.coordinates, LineString.apply) + + implicit val multiLineStringCodec: JsonValueCodec[MultiLineString] = + makeGeometryCodec("MultiLineString", _.coordinates, MultiLineString.apply) + + implicit val polygonCodec: JsonValueCodec[Polygon] = + makeGeometryCodec("Polygon", _.coordinates, Polygon.apply) + + implicit val multiPolygonCodec: JsonValueCodec[MultiPolygon] = + makeGeometryCodec("MultiPolygon", _.coordinates, MultiPolygon.apply) + + private[this] def makeGeometryCodec[C, G <: Geometry](tpe: String, coords: G => C, geom: C => G)(implicit + coordinatesCodec: JsonValueCodec[C] + ): JsonValueCodec[G] = + new JsonValueCodec[G] { + override val nullValue: G = null.asInstanceOf[G] + + override def decodeValue(in: JsonReader, default: G): G = + if (in.isNextToken('{')) { + var coordinates: C = coordinatesCodec.nullValue + var mask = 3 + var len = -1 + while (len < 0 || in.isNextToken(',')) { + len = in.readKeyAsCharBuf() + if (in.isCharBufEqualsTo(len, "type")) { + if ((mask & 0x1) != 0) mask ^= 0x1 + else in.duplicatedKeyError(len) + if (!in.isCharBufEqualsTo(in.readStringAsCharBuf(), tpe)) { + in.discriminatorValueError("type") + } + } else if (in.isCharBufEqualsTo(len, "coordinates")) { + if ((mask & 0x2) != 0) mask ^= 0x2 + else in.duplicatedKeyError(len) + coordinates = coordinatesCodec.decodeValue(in, coordinates) + } else in.skip() + } + if (!in.isCurrentToken('}')) in.objectEndOrCommaError() + if (mask != 0) error(in, mask) + geom(coordinates) + } else in.readNullOrTokenError(default, '}') + + override def encodeValue(x: G, out: JsonWriter): Unit = { + out.writeObjectStart() + out.writeNonEscapedAsciiKey("type") + out.writeNonEscapedAsciiVal(tpe) + out.writeNonEscapedAsciiKey("coordinates") + coordinatesCodec.encodeValue(coords(x), out) + out.writeObjectEnd() + } + + private[this] def error(in: JsonReader, mask: Int): Nothing = + in.requiredFieldError { + if ((mask & 0x1) != 0) "type" + else "coordinates" + } + } + + implicit val geometryCodec: JsonValueCodec[Geometry] = JsonCodecMaker.make + + implicit def featureCodec[P](implicit propertiesCodec: JsonValueCodec[P]): JsonValueCodec[Feature[P]] = + new JsonValueCodec[Feature[P]] { + override val nullValue: Feature[P] = null.asInstanceOf[Feature[P]] + + override def decodeValue(in: JsonReader, default: Feature[P]): Feature[P] = + if (in.isNextToken('{')) { + var properties: P = propertiesCodec.nullValue + var geometry: Geometry = geometryCodec.nullValue + var mask = 7 + var len = -1 + while (len < 0 || in.isNextToken(',')) { + len = in.readKeyAsCharBuf() + if (in.isCharBufEqualsTo(len, "type")) { + if ((mask & 0x1) != 0) mask ^= 0x1 + else in.duplicatedKeyError(len) + if (!in.isCharBufEqualsTo(in.readStringAsCharBuf(), "Feature")) { + in.discriminatorValueError("type") + } + } else if (in.isCharBufEqualsTo(len, "properties")) { + if ((mask & 0x2) != 0) mask ^= 0x2 + else in.duplicatedKeyError(len) + properties = propertiesCodec.decodeValue(in, properties) + } else if (in.isCharBufEqualsTo(len, "geometry")) { + if ((mask & 0x4) != 0) mask ^= 0x4 + else in.duplicatedKeyError(len) + geometry = geometryCodec.decodeValue(in, geometry) + } else in.skip() + } + if (!in.isCurrentToken('}')) in.objectEndOrCommaError() + if (mask != 0) error(in, mask) + new Feature(properties, geometry) + } else in.readNullOrTokenError(default, '}') + + override def encodeValue(x: Feature[P], out: JsonWriter): Unit = { + out.writeObjectStart() + out.writeNonEscapedAsciiKey("type") + out.writeNonEscapedAsciiVal("Feature") + out.writeNonEscapedAsciiKey("properties") + propertiesCodec.encodeValue(x.properties, out) + out.writeNonEscapedAsciiKey("geometry") + geometryCodec.encodeValue(x.geometry, out) + out.writeObjectEnd() + } + + private[this] def error(in: JsonReader, mask: Int): Nothing = + in.requiredFieldError { + if ((mask & 0x1) != 0) "type" + else if ((mask & 0x2) != 0) "properties" + else "geometry" + } + } + + implicit def featureCollectionCodec[P](implicit featureCodec: JsonValueCodec[Feature[P]]): JsonValueCodec[FeatureCollection[P]] = + new JsonValueCodec[FeatureCollection[P]] { + override val nullValue: FeatureCollection[P] = null.asInstanceOf[FeatureCollection[P]] + + override def decodeValue(in: JsonReader, default: FeatureCollection[P]): FeatureCollection[P] = + if (in.isNextToken('{')) { + var features: List[Feature[P]] = Nil + var mask = 3 + var len = -1 + while (len < 0 || in.isNextToken(',')) { + len = in.readKeyAsCharBuf() + if (in.isCharBufEqualsTo(len, "type")) { + if ((mask & 0x1) != 0) mask ^= 0x1 + else in.duplicatedKeyError(len) + if (!in.isCharBufEqualsTo(in.readStringAsCharBuf(), "FeatureCollection")) { + in.discriminatorValueError("type") + } + } else if (in.isCharBufEqualsTo(len, "features")) { + if ((mask & 0x2) != 0) mask ^= 0x2 + else in.duplicatedKeyError(len) + if (in.isNextToken('[')) { + if (!in.isNextToken(']')) { + in.rollbackToken() + val buf = new ListBuffer[Feature[P]] + while ({ + buf += featureCodec.decodeValue(in, featureCodec.nullValue) + in.isNextToken(',') + }) () + if (in.isCurrentToken(']')) features = buf.toList + else in.arrayEndOrCommaError() + } else in.readNullOrTokenError(features, '[') + } + } else in.skip() + } + if (!in.isCurrentToken('}')) in.objectEndOrCommaError() + if (mask != 0) error(in, mask) + new FeatureCollection(features) + } else in.readNullOrTokenError(default, '}') + + override def encodeValue(x: FeatureCollection[P], out: JsonWriter): Unit = { + out.writeObjectStart() + out.writeNonEscapedAsciiKey("type") + out.writeNonEscapedAsciiVal("FeatureCollection") + out.writeNonEscapedAsciiKey("features") + out.writeArrayStart() + var remainingFeatures = x.features + while (remainingFeatures ne Nil) { + featureCodec.encodeValue(remainingFeatures.head, out) + remainingFeatures = remainingFeatures.tail + } + out.writeArrayEnd() + out.writeObjectEnd() + } + + private[this] def error(in: JsonReader, mask: Int): Nothing = + in.requiredFieldError { + if ((mask & 0x1) != 0) "type" + else "features" + } + } + + implicit def geoJson[P: JsonValueCodec]: JsonValueCodec[GeoJson[P]] = + new JsonValueCodec[GeoJson[P]] { + private[this] val fc: JsonValueCodec[Feature[P]] = featureCodec + private[this] val fcc: JsonValueCodec[FeatureCollection[P]] = featureCollectionCodec + override val nullValue: GeoJson[P] = null.asInstanceOf[GeoJson[P]] + + override def decodeValue(in: JsonReader, default: GeoJson[P]): GeoJson[P] = { + in.setMark() + if (in.isNextToken('{')) { + if (!in.skipToKey("type")) in.discriminatorError() + val len = in.readStringAsCharBuf() + in.rollbackToMark() + if (in.isCharBufEqualsTo(len, "Feature")) fc.decodeValue(in, fc.nullValue) + else if (in.isCharBufEqualsTo(len, "FeatureCollection")) fcc.decodeValue(in, fcc.nullValue) + else geometryCodec.decodeValue(in, geometryCodec.nullValue).asInstanceOf[GeoJson[P]] + } else { + val gj = in.readNullOrTokenError(default, '{') + in.rollbackToMark() + in.skip() + gj + } + } + + override def encodeValue(x: GeoJson[P], out: JsonWriter): Unit = + x match { + case f: Feature[P] => fc.encodeValue(f, out) + case fc: FeatureCollection[P] => fcc.encodeValue(fc, out) + case _ => geometryCodec.encodeValue(x.asInstanceOf[Geometry], out) + } + } +} diff --git a/jsoniter-scala/src/test/scala/com/free2move/geoscala/JsoniterScalaCodecTests.scala b/jsoniter-scala/src/test/scala/com/free2move/geoscala/JsoniterScalaCodecTests.scala new file mode 100644 index 0000000..4e78bb0 --- /dev/null +++ b/jsoniter-scala/src/test/scala/com/free2move/geoscala/JsoniterScalaCodecTests.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2019 GHM Mobile Development GmbH + * + * 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 com.free2move.geoscala + +import com.free2move.geoscala.jsoniter_scala._ +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class JsoniterScalaDecodingAndEncodingTests extends AnyFlatSpec with Matchers with EitherValues { + "The jsoniter-scala codecs" should "parse and serialize simple 2D points" in { + val json = """{"type":"Point","coordinates":[12.3046875,51.8357775]}""" + val obj = Point(Coordinate(12.3046875, 51.8357775)) + readFromString[Point](json) shouldBe obj + readFromString[Geometry](json) shouldBe obj + writeToString[Point](obj) shouldBe json + writeToString[Geometry](obj) shouldBe json + } + + it should "parse points with more dimensions as 2D points" in { + val json = """{"type":"Point","coordinates":[12.3046875,51.8357775,7.000,42.12345]}""" + val obj = Point(Coordinate(12.3046875, 51.8357775)) + readFromString[Point](json) shouldBe obj + readFromString[Geometry](json) shouldBe obj + } + + it should "pare and serialize FeatureCollection using provided codec for properties" in { + type Json = Map[String, Int] + + implicit val jsonCodec: JsonValueCodec[Json] = JsonCodecMaker.make + + val json = + """{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":7},"geometry":{"type":"Point","coordinates":[12.3046875,51.8357775]}}]}""" + val obj = + FeatureCollection(List(Feature(Map("id" -> 7), Point(Coordinate(12.3046875, 51.8357775))))) + readFromString[FeatureCollection[Json]](json) shouldBe obj + readFromString[GeoJson[Json]](json) shouldBe obj + writeToString[FeatureCollection[Json]](obj) shouldBe json + writeToString[GeoJson[Json]](obj) shouldBe json + } +} diff --git a/project/plugins.sbt b/project/plugins.sbt index cdb11b1..095ac22 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,5 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.4.13") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.1") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.5") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.11.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.2.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.7") addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.2.0")