diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85e988e..e01d647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ on: tags: [v*] env: - PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} - SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} - SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} - PGP_SECRET: ${{ secrets.PGP_SECRET }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build and Test @@ -29,98 +29,57 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - scala: [2.12.18, 2.13.11, 3.3.0] - java: [temurin@8, temurin@11, temurin@17] + scala: [2.12, 2.13, 3] + java: [temurin@11, temurin@17] project: [rootJS, rootJVM, rootNative] exclude: - - scala: 2.12.18 - java: temurin@11 - - scala: 2.12.18 + - scala: 2.12 java: temurin@17 - - scala: 3.3.0 - java: temurin@11 - - scala: 3.3.0 + - scala: 3 java: temurin@17 - - project: rootJS - java: temurin@11 - project: rootJS java: temurin@17 - - project: rootNative - java: temurin@11 - project: rootNative java: temurin@17 runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Download Java (temurin@11) - id: download-java-temurin-11 + - name: Setup Java (temurin@11) + id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 11 + cache: sbt - - name: Setup Java (temurin@11) - if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} - - - name: Download Java (temurin@17) - id: download-java-temurin-17 - if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 17 + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update - name: Setup Java (temurin@17) + id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} + cache: sbt - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update - name: Check that workflows are up to date run: sbt githubWorkflowCheck - name: Check headers and formatting - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck - name: scalaJSLink @@ -135,28 +94,28 @@ jobs: run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc - name: Check scalafix lints - if: matrix.java == 'temurin@8' && !startsWith(matrix.scala, '3.') + if: matrix.java == 'temurin@11' && !startsWith(matrix.scala, '3') run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check' - name: Check unused compile dependencies - if: matrix.java == 'temurin@8' + if: matrix.java == 'temurin@11' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' unusedCompileDependenciesTest - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p target .js/target cbor/.js/target site/target csv/.jvm/target xml/.jvm/target xml-scala/.js/target xml/.js/target cbor/.native/target xml-scala/.native/target cbor/.jvm/target xml-scala/.jvm/target csv/.native/target .jvm/target csv/.js/target .native/target xml/.native/target project/target + run: mkdir -p json/.native/target cbor/.js/target json/.jvm/target csv/.jvm/target xml/.jvm/target xml-scala/.js/target xml/.js/target cbor/.native/target xml-scala/.native/target cbor/.jvm/target xml-scala/.jvm/target csv/.native/target csv/.js/target xml/.native/target json/.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 target .js/target cbor/.js/target site/target csv/.jvm/target xml/.jvm/target xml-scala/.js/target xml/.js/target cbor/.native/target xml-scala/.native/target cbor/.jvm/target xml-scala/.jvm/target csv/.native/target .jvm/target csv/.js/target .native/target xml/.native/target project/target + run: tar cf targets.tar json/.native/target cbor/.js/target json/.jvm/target csv/.jvm/target xml/.jvm/target xml-scala/.js/target xml/.js/target cbor/.native/target xml-scala/.native/target cbor/.jvm/target xml-scala/.jvm/target csv/.native/target csv/.js/target xml/.native/target json/.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') @@ -172,257 +131,245 @@ jobs: strategy: matrix: os: [ubuntu-latest] - java: [temurin@8] + java: [temurin@11] runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Download Java (temurin@11) - id: download-java-temurin-11 + - name: Setup Java (temurin@11) + id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 11 + cache: sbt - - name: Setup Java (temurin@11) - if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update - - name: Download Java (temurin@17) - id: download-java-temurin-17 + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 + cache: sbt - - name: Setup Java (temurin@17) - if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Download target directories (2.12.18, rootJS) + - name: Download target directories (2.12, rootJS) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.18-rootJS + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJS - - name: Inflate target directories (2.12.18, rootJS) + - name: Inflate target directories (2.12, rootJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.18, rootJVM) + - name: Download target directories (2.12, rootJVM) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.18-rootJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM - - name: Inflate target directories (2.12.18, rootJVM) + - name: Inflate target directories (2.12, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.12.18, rootNative) + - name: Download target directories (2.12, rootNative) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.18-rootNative + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootNative - - name: Inflate target directories (2.12.18, rootNative) + - name: Inflate target directories (2.12, rootNative) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.11, rootJS) + - name: Download target directories (2.13, rootJS) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11-rootJS + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJS - - name: Inflate target directories (2.13.11, rootJS) + - name: Inflate target directories (2.13, rootJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.11, rootJVM) + - name: Download target directories (2.13, rootJVM) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11-rootJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJVM - - name: Inflate target directories (2.13.11, rootJVM) + - name: Inflate target directories (2.13, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.11, rootNative) + - name: Download target directories (2.13, rootNative) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11-rootNative + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootNative - - name: Inflate target directories (2.13.11, rootNative) + - name: Inflate target directories (2.13, rootNative) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.3.0, rootJS) + - name: Download target directories (3, rootJS) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.0-rootJS + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS - - name: Inflate target directories (3.3.0, rootJS) + - name: Inflate target directories (3, rootJS) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.3.0, rootJVM) + - name: Download target directories (3, rootJVM) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.0-rootJVM + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM - - name: Inflate target directories (3.3.0, rootJVM) + - name: Inflate target directories (3, rootJVM) run: | tar xf targets.tar rm targets.tar - - name: Download target directories (3.3.0, rootNative) + - name: Download target directories (3, rootNative) uses: actions/download-artifact@v3 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.0-rootNative + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative - - name: Inflate target directories (3.3.0, rootNative) + - name: Inflate target directories (3, rootNative) run: | tar xf targets.tar rm targets.tar - name: Import signing key if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' - run: echo $PGP_SECRET | base64 -di | gpg --import + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: echo $PGP_SECRET | base64 -d -i - | gpg --import - name: Import signing key and strip passphrase if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} run: | - echo "$PGP_SECRET" | base64 -di > /tmp/signing-key.gpg + echo "$PGP_SECRET" | base64 -d -i - > /tmp/signing-key.gpg echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) - name: Publish + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} run: sbt tlCiRelease - site: - name: Generate Site + dependency-submission: + name: Submit Dependencies + if: github.event_name != 'pull_request' strategy: matrix: os: [ubuntu-latest] - java: [temurin@8] + java: [temurin@11] runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 + - name: Setup Java (temurin@11) + id: setup-java-temurin-11 + if: matrix.java == 'temurin@11' + uses: actions/setup-java@v4 with: distribution: temurin - java-version: 8 + java-version: 11 + cache: sbt - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update - - name: Download Java (temurin@11) - id: download-java-temurin-11 - if: matrix.java == 'temurin@11' - uses: typelevel/download-java@v2 + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v4 with: distribution: temurin - java-version: 11 + java-version: 17 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update + + - name: Submit Dependencies + uses: scalacenter/sbt-dependency-submission@v2 + with: + modules-ignore: rootjs_2.12 rootjs_2.13 rootjs_3 docs_2.12 docs_2.13 docs_3 rootjvm_2.12 rootjvm_2.13 rootjvm_3 rootnative_2.12 rootnative_2.13 rootnative_3 sbt-http4s-org-scalafix-internal_2.12 sbt-http4s-org-scalafix-internal_2.13 sbt-http4s-org-scalafix-internal_3 + configs-ignore: test scala-tool scala-doc-tool test-internal + + site: + name: Generate Site + strategy: + matrix: + os: [ubuntu-latest] + java: [temurin@11] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Java (temurin@11) + id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 11 - jdkFile: ${{ steps.download-java-temurin-11.outputs.jdkFile }} + cache: sbt - - name: Download Java (temurin@17) - id: download-java-temurin-17 - if: matrix.java == 'temurin@17' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 17 + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update - name: Setup Java (temurin@17) + id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 17 - jdkFile: ${{ steps.download-java-temurin-17.outputs.jdkFile }} + cache: sbt - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update - name: Generate site run: sbt docs/tlSite - name: Publish site if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v3.9.0 + uses: peaceiris/actions-gh-pages@v3.9.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: site/target/docs/site diff --git a/.scalafmt.conf b/.scalafmt.conf index da9b6ec..dd3c89a 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.7.12 +version = 3.7.17 style = default diff --git a/README.md b/README.md index 590f384..0dc18aa 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Home of http4s integrations with [fs2-data][fs2-data]. Initially forked from [ht * XML and Scala XML. Works as a drop-in replacement for [http4s-scala-xml][http4s-scala-xml] * CSV * CBOR +* JSON Check out the [docs][docs] for examples. diff --git a/build.sbt b/build.sbt index 994d958..20a21b4 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,14 @@ -ThisBuild / tlBaseVersion := "0.2" +ThisBuild / tlBaseVersion := "0.3" +ThisBuild / tlJdkRelease := Some(11) +// exclude Java 8 from CI as fs2-data doesn't support it +ThisBuild / githubWorkflowJavaVersions -= JavaSpec.temurin("8") ThisBuild / developers := List( tlGitHubDev("rossabaker", "Ross A. Baker"), tlGitHubDev("ybasket", "Yannick Heiber"), ) -val Scala213 = "2.13.11" -ThisBuild / crossScalaVersions := Seq("2.12.18", Scala213, "3.3.0") +val Scala213 = "2.13.12" +ThisBuild / crossScalaVersions := Seq("2.12.18", Scala213, "3.3.1") ThisBuild / scalaVersion := Scala213 // ensure missing timezones don't break tests on JS @@ -14,13 +17,17 @@ ThisBuild / jsEnv := { new NodeJSEnv(NodeJSEnv.Config().withEnv(Map("TZ" -> "UTC"))) } +// ensure we don't fail compilation – package objects with inheritance are used in http4s/http4s as well, +// better to stay style-consistent for now +ThisBuild / scalacOptions += "-Wconf:msg=package object inheritance is deprecated:s" + lazy val root = tlCrossRootProject.aggregate(xml, xmlScala, csv, cbor) -val http4sVersion = "0.23.23" +val http4sVersion = "0.23.25" val scalaXmlVersion = "2.2.0" -val fs2Version = "3.8.0" -val fs2DataVersion = "1.8.0" -val munitVersion = "1.0.0-M8" +val fs2Version = "3.9.4" +val fs2DataVersion = "1.10.0" +val munitVersion = "1.0.0-M10" val munitCatsEffectVersion = "2.0.0-M4" lazy val xml = crossProject(JVMPlatform, JSPlatform, NativePlatform) @@ -66,7 +73,6 @@ lazy val csv = crossProject(JVMPlatform, JSPlatform, NativePlatform) name := "http4s-fs2-data-csv", description := "Provides csv codecs for http4s via fs2-data", startYear := Some(2023), - tlVersionIntroduced := Map("2.12" -> "0.2", "2.13" -> "0.2", "3" -> "0.2"), libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % fs2Version, "org.http4s" %%% "http4s-core" % http4sVersion, @@ -85,7 +91,6 @@ lazy val cbor = crossProject(JVMPlatform, JSPlatform, NativePlatform) name := "http4s-fs2-data-cbor", description := "Provides CBOR codecs for http4s via fs2-data", startYear := Some(2023), - tlVersionIntroduced := Map("2.12" -> "0.2", "2.13" -> "0.2", "3" -> "0.2"), libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % fs2Version, "org.http4s" %%% "http4s-core" % http4sVersion, @@ -96,9 +101,26 @@ lazy val cbor = crossProject(JVMPlatform, JSPlatform, NativePlatform) ), ) +lazy val json = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("json")) + .settings( + name := "http4s-fs2-data-json", + description := "Provides JSON codecs for http4s via fs2-data", + startYear := Some(2024), + libraryDependencies ++= Seq( + "co.fs2" %%% "fs2-core" % fs2Version, + "org.http4s" %%% "http4s-core" % http4sVersion, + "org.gnieh" %%% "fs2-data-json" % fs2DataVersion, + "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test, + "org.typelevel" %%% "munit-cats-effect" % munitCatsEffectVersion % Test, + "org.http4s" %%% "http4s-laws" % http4sVersion % Test, + ), + ) + lazy val docs = project .in(file("site")) - .dependsOn(xml.jvm, xmlScala.jvm, csv.jvm, cbor.jvm) + .dependsOn(xml.jvm, xmlScala.jvm, csv.jvm, cbor.jvm, json.jvm) .settings( libraryDependencies ++= Seq( "io.circe" %%% "circe-generic" % "0.14.5", diff --git a/docs/index.md b/docs/index.md index 70b7190..e60e5a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -153,3 +153,58 @@ curl -s -X "POST" "http://localhost:8080/csv/toCbor" \ Then copy the output to [https://cbor.me](https://cbor.me) or a similar CBOR viewer. Make sure to view as `cborseq` otherwise the output will be truncated. + + +## http4s-fs2-data-json + +Provides basic support for parsing and encoding `fs2.data.json.Token` streams that can be handled in a streaming fashion +using the pipes and builders `fs2-data` provides. + +```scala +libraryDependencies += "org.http4s" %% "http4s-fs2-data-json" % "@VERSION@" +``` + +### Example + +This example consumes a JSON input and returns it pretty printed. + +```scala mdoc +import cats.effect.Async +import org.http4s.{EntityDecoder, EntityEncoder, HttpRoutes} +import org.http4s.dsl.Http4sDsl +import fs2.Stream +import fs2.data.json.Token + +class JsonHttpEndpoint[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { + + private implicit val payloadDecoder: EntityDecoder[F, Stream[F, Token]] = + org.http4s.fs2data.json.jsonTokensDecoder + + private implicit val payloadEncoder: EntityEncoder[F, Stream[F, Token]] = + org.http4s.fs2data.json.jsonTokensEncoder(prettyPrint = true) + + val service: HttpRoutes[F] = HttpRoutes.of { + case req @ POST -> Root / "prettyJson" => + Ok(Stream.force(req.as[Stream[F, Token]])) + } +} +``` + +You can try yourself with this snippet: + +```shell +curl -s -X "POST" "http://localhost:8080/prettyJson" \ + -H 'Content-Type: text/json; charset=utf-8' \ + -d '{"a":2024,"b":[true,false],"c":{"d":"e"},"d":1}' +{ + "a": 2024, + "b": [ + true, + false + ], + "c": { + "d": "e" + }, + "d": 1 +} +``` \ No newline at end of file diff --git a/json/src/main/scala/org/http4s/fs2data/json/JsonInstances.scala b/json/src/main/scala/org/http4s/fs2data/json/JsonInstances.scala new file mode 100644 index 0000000..915734d --- /dev/null +++ b/json/src/main/scala/org/http4s/fs2data/json/JsonInstances.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2023 http4s.org + * + * 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 org.http4s +package fs2data.json + +import cats.data.NonEmptyList +import cats.effect.Concurrent +import cats.syntax.applicative._ +import cats.syntax.monadError._ +import fs2.data.json._ +import fs2.Stream +import cats.syntax.show._ +import org.http4s.Charset.`UTF-8` +import org.http4s.headers.{`Content-Type`, `Transfer-Encoding`} + +trait JsonInstances { + + implicit def jsonTokensDecoder[F[_]: Concurrent]: EntityDecoder[F, Stream[F, Token]] = + EntityDecoder.decodeBy(MediaType.application.json) { msg => + DecodeResult.successT( + msg.bodyText + .through(tokens) + .adaptError { case ex: JsonException => + MalformedMessageBodyFailure( + s"Invalid Json (${ex.context.fold("No context")(jc => jc.show)}): ${ex.msg}", + Some(ex), + ) + } + ) + } + + def jsonTokensEncoder[F[_]](prettyPrint: Boolean)(implicit + charset: Charset = `UTF-8` + ): EntityEncoder[F, Stream[F, Token]] = EntityEncoder.encodeBy( + Headers( + `Content-Type`(MediaType.application.json).withCharset(charset), + `Transfer-Encoding`(TransferCoding.chunked.pure[NonEmptyList]), + ) + ) { tokens => + Entity( + tokens + .through( + if (prettyPrint) render.pretty() else render.compact + ) + .through(fs2.text.encode[F](charset.nioCharset)) + ) + } + + implicit def jsonTokensEncoder[F[_]]: EntityEncoder[F, Stream[F, Token]] = + jsonTokensEncoder(prettyPrint = false) + +} diff --git a/json/src/main/scala/org/http4s/fs2data/json/package.scala b/json/src/main/scala/org/http4s/fs2data/json/package.scala new file mode 100644 index 0000000..1ee1797 --- /dev/null +++ b/json/src/main/scala/org/http4s/fs2data/json/package.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2023 http4s.org + * + * 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 org.http4s.fs2data + +package object json extends JsonInstances diff --git a/json/src/test/scala/org/http4s/fs2data/json/JsonEventSuite.scala b/json/src/test/scala/org/http4s/fs2data/json/JsonEventSuite.scala new file mode 100644 index 0000000..308ee7c --- /dev/null +++ b/json/src/test/scala/org/http4s/fs2data/json/JsonEventSuite.scala @@ -0,0 +1,55 @@ +package org.http4s.fs2data.json + +import cats.effect.IO +import cats.syntax.all._ +import fs2.Stream +import fs2.data.json._ +import fs2.data.json.literals.JsonInterpolator +import munit.CatsEffectSuite +import munit.ScalaCheckEffectSuite +import org.http4s.EntityDecoder +import org.http4s.Request + +class JsonEventSuite extends CatsEffectSuite with ScalaCheckEffectSuite { + test("round-trip Json") { + val in = json"""{"a": 1, "b": [true, false, null], "c": {"d": "e"}, "d": 1.2e3, "b": null}""" + Stream + .force(Request[IO]().withEntity(in.lift[IO]).as[Stream[IO, Token]]) + .compile + .toList + .map(_.asRight[Throwable]) + .assertEquals(in.toList) + } + + test("round-trip Json string") { + val in = """{"a":1,"b":[true,false,null],"c":{"d":"e"},"d":1.2e3,"b":null}""" + Request[IO]() + .withEntity(in) + .as[Stream[IO, Token]] + .map(Request[IO]().withEntity(_)) + .flatMap(EntityDecoder.text[IO].decode(_, false).value) + .assertEquals(Right(in)) + } + + test("round-trip Json pretty printing") { + val in = """{ + | "a": 1, + | "b": [ + | true, + | false, + | null + | ], + | "c": { + | "d": "e" + | }, + | "d": 1.2e3, + | "b": null + |}""".stripMargin + Request[IO]() + .withEntity(in) + .as[Stream[IO, Token]] + .map(Request[IO]().withEntity(_)(jsonTokensEncoder[IO](prettyPrint = true))) + .flatMap(EntityDecoder.text[IO].decode(_, false).value) + .assertEquals(Right(in)) + } +} diff --git a/project/build.properties b/project/build.properties index 3040987..abbbce5 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.4 +sbt.version=1.9.8 diff --git a/project/plugins.sbt b/project/plugins.sbt index 42521c1..645a829 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,8 +1,8 @@ -addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.14.13") +addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.16.2") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.2") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.15.0") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.14") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17") addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") diff --git a/xml-scala/src/test/scala/org/http4s/fs2data/xml/scalaxml/generators.scala b/xml-scala/src/test/scala/org/http4s/fs2data/xml/scalaxml/generators.scala index 9e057fb..57cb21c 100644 --- a/xml-scala/src/test/scala/org/http4s/fs2data/xml/scalaxml/generators.scala +++ b/xml-scala/src/test/scala/org/http4s/fs2data/xml/scalaxml/generators.scala @@ -434,7 +434,10 @@ object generators { n <- Gen.poisson(5) s <- Gen.stringOfN(n, Gen.oneOf(char)) // Text may not contain these two in literal form, §2.4 of XML syntax - r = s.replace("&", "&").replace("<", "<") + // We replace them by empty strings instead of their quoted versions because Scala XML fails the roundtrip + // Relates to https://github.com/scala/scala-xml/issues/57 + // Works around https://github.com/http4s/http4s-fs2-data/issues/88 + r = s.replace("&", "").replace("<", "") } yield Text(r) val genComment: Gen[Comment] =