diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a338ac3..48e1a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,9 @@ name: Continuous Integration on: pull_request: - branches: ['**'] + branches: ['**', '!update/**', '!pr/**'] push: - branches: ['**'] + branches: ['**', '!update/**', '!pr/**'] tags: [v*] env: @@ -29,12 +29,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - scala: [2.13.8, 3.1.2] + scala: [2.13.8, 3.2.1] java: [temurin@8, temurin@11, temurin@17] exclude: - - scala: 3.1.2 + - scala: 3.2.1 java: temurin@11 - - scala: 3.1.2 + - scala: 3.2.1 java: temurin@17 runs-on: ${{ matrix.os }} steps: @@ -104,7 +104,7 @@ jobs: key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - name: Check that workflows are up to date - run: sbt '++${{ matrix.scala }}' 'project /' githubWorkflowCheck + run: sbt githubWorkflowCheck - name: Check headers and formatting if: matrix.java == 'temurin@8' @@ -121,17 +121,21 @@ jobs: if: matrix.java == 'temurin@8' run: sbt '++${{ matrix.scala }}' doc + - name: Check scalafix lints + if: matrix.java == 'temurin@8' && !startsWith(matrix.scala, '3.') + run: sbt '++${{ matrix.scala }}' 'scalafixAll --check' + - name: Check unused compile dependencies if: matrix.java == 'temurin@8' run: sbt '++${{ 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 scala-xml/target site/target project/target + run: mkdir -p target scala-xml-2/target site/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 scala-xml/target site/target project/target + run: tar cf targets.tar target scala-xml-2/target site/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -226,32 +230,12 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (2.13.8) - uses: actions/download-artifact@v2 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.8 - - - name: Inflate target directories (2.13.8) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.13.8) - uses: actions/download-artifact@v2 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.8 - - - name: Inflate target directories (2.13.8) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (3.1.2) + - name: Download target directories (3.2.1) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.2 + name: target-${{ matrix.os }}-${{ matrix.java }}-3.2.1 - - name: Inflate target directories (3.1.2) + - name: Inflate target directories (3.2.1) run: | tar xf targets.tar rm targets.tar diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..ab1b91a --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,15 @@ +rules = [ + Http4sFs2Linters + Http4sGeneralLinters + Http4sUseLiteralsSyntax + LeakingImplicitClassVal + ExplicitResultTypes + OrganizeImports +] + +triggered.rules = [ + Http4sFs2Linters + Http4sGeneralLinters + Http4sUseLiteralsSyntax + LeakingImplicitClassVal +] diff --git a/.scalafmt.conf b/.scalafmt.conf index 69f7480..1e85fda 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.5.8 +version = 3.6.1 style = default diff --git a/README.md b/README.md new file mode 100644 index 0000000..59e6cf4 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# http4s-scala-xml + +Provides http4s entity codec instances for [scala-xml]. + +## `http4s-scala-xml` + +This is probably the artifact you want. It works with scala-xml-2.x. + +### SBT coordinates + +```scala +libraryDependencies ++= Seq( + "org.http4s" %% "http4s-scala-xml" % http4sScalaXmlV +) +``` + +## `http4s-scala-xml-1` + +This repository also publishes an alternate `http4s-scala-xml-1` +artifact. The Scala package is the same, so this dependency must +never be bundled with `http4s-scala-xml`. It exists because several +signficant libraries, like [Twirl], are still based on scala-xml-1.x +in Scala 2. Use this library to avoid diamond dependencies, but +upgrade when you can. + +### SBT coordinates + +```scala +libraryDependencies ++= Seq( + "org.http4s" %% "http4s-scala-xml-1" % http4sScalaXmlV +) +``` + + +## Compatibility + +| artifact | version | http4s-core | scala-xml | Scala 2.12 | Scala 2.13 | Scala 3 | Status | | +|:-------------------|:--------|:------------|:----------|------------|------------|---------|--------|---| +| http4s-scala-xml | 0.23.x | 0.23.x | 2.x | ✅ | ✅ | ✅ | Stable | | +| http4s-scala-xml-1 | 0.23.x | 0.23.x | 1.x | ✅ | ✅ | ❌ | Stable | | + +[scala-xml]: https://github.com/scala/scala-xml +[twirl]: https://github.com/playframework/twirl diff --git a/build.sbt b/build.sbt index 0be8258..4074ba3 100644 --- a/build.sbt +++ b/build.sbt @@ -2,36 +2,45 @@ ThisBuild / tlBaseVersion := "1.0" ThisBuild / developers := List( tlGitHubDev("rossabaker", "Ross A. Baker") ) +ThisBuild / startYear := Some(2014) val Scala213 = "2.13.8" -ThisBuild / crossScalaVersions := Seq(Scala213, "3.1.2") +ThisBuild / crossScalaVersions := Seq(Scala213, "3.2.1") ThisBuild / scalaVersion := Scala213 -lazy val root = project.in(file(".")).aggregate(scalaXml).enablePlugins(NoPublishPlugin) +lazy val root = project.in(file(".")).aggregate(scalaXml2).enablePlugins(NoPublishPlugin) -val http4sVersion = "1.0.0-M35" +val http4sVersion = "1.0.0-M38" +val scalacheckXmlVersion = "0.1.0" val scalaXmlVersion = "2.1.0" -val munitVersion = "0.7.29" -val munitCatsEffectVersion = "1.0.7" +val munitVersion = "1.0.0-M7" +val munitCatsEffectVersion = "2.0.0-M3" -lazy val scalaXml = project - .in(file("scala-xml")) +lazy val scalaXml2 = project + .in(file("scala-xml-2")) .settings( name := "http4s-scala-xml", description := "Provides scala-xml codecs for http4s", - startYear := Some(2014), - libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-core" % http4sVersion, - "org.scala-lang.modules" %%% "scala-xml" % scalaXmlVersion, - "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test, - "org.typelevel" %%% "munit-cats-effect-3" % munitCatsEffectVersion % Test, - "org.http4s" %%% "http4s-laws" % http4sVersion % Test, - ), + tlMimaPreviousVersions ++= (0 to 11).map(y => s"0.23.$y").toSet, + libraryDependencies += "org.scala-lang.modules" %%% "scala-xml" % scalaXmlVersion, + commonSettings, ) +lazy val commonSettings = Seq( + Compile / unmanagedSourceDirectories += (LocalRootProject / baseDirectory).value / "scala-xml" / "src" / "main" / "scala", + Test / unmanagedSourceDirectories += (LocalRootProject / baseDirectory).value / "scala-xml" / "src" / "test" / "scala", + libraryDependencies ++= Seq( + "org.http4s" %%% "http4s-core" % http4sVersion, + "org.http4s" %%% "http4s-laws" % http4sVersion % Test, + "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test, + "org.typelevel" %%% "munit-cats-effect" % munitCatsEffectVersion % Test, + "org.typelevel" %%% "scalacheck-xml" % scalacheckXmlVersion % Test, + ), +) + lazy val docs = project .in(file("site")) - .dependsOn(scalaXml) + .dependsOn(scalaXml2) .settings( libraryDependencies ++= Seq( "org.http4s" %%% "http4s-dsl" % http4sVersion, diff --git a/docs/index.md b/docs/index.md index c9e4190..111ff74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ class JsonXmlHttpEndpoint[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { } private def personXmlDecoder: EntityDecoder[F, Person] = - org.http4s.scalaxml.xml[F].map(Person.fromXml) + org.http4s.scalaxml.xmlDecoder[F].map(Person.fromXml) implicit private def jsonXmlDecoder: EntityDecoder[F, Person] = jsonOf[F, Person].orElse(personXmlDecoder) @@ -55,4 +55,4 @@ class JsonXmlHttpEndpoint[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { } } } -``` \ No newline at end of file +``` diff --git a/project/build.properties b/project/build.properties index c8fcab5..c13a9b3 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.6.2 +sbt.version=1.8.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 2749866..5f6bf95 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1 @@ -addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.13.4") +addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.14.7") diff --git a/scala-xml-1/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala b/scala-xml-1/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala new file mode 100644 index 0000000..fedcc5d --- /dev/null +++ b/scala-xml-1/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2014 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.scalaxml + +import scala.xml._ +import scala.xml.transform._ + +trait ScalaXmlSuiteVersion { + object stripComments extends RewriteRule { + override def transform(n: Node): Seq[Node] = + n match { + case _: Comment => Seq.empty + case n => Seq(n) + } + } + + object trimProper extends RewriteRule { + override def transform(n: Node): Seq[Node] = + Utility.trimProper(n) + } + + // https://github.com/http4s/http4s-scala-xml/issues/32 + object normalize extends RuleTransformer(stripComments, trimProper) +} diff --git a/scala-xml-2/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala b/scala-xml-2/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala new file mode 100644 index 0000000..5f38d96 --- /dev/null +++ b/scala-xml-2/src/test/scala/org/http4s/scalaxml/ScalaXmlSuiteVersion.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2014 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.scalaxml + +import scala.xml.transform._ + +trait ScalaXmlSuiteVersion { + // https://github.com/http4s/http4s-scala-xml/issues/32 + // + // Nothing to do here but make the tests compatible with + // scala-xml-1's parser. + object normalize extends RuleTransformer() +} diff --git a/scala-xml/src/main/scala/org/http4s/scalaxml/ElemInstances.scala b/scala-xml/src/main/scala/org/http4s/scalaxml/ElemInstances.scala index 46f2ed4..d1c9a4d 100644 --- a/scala-xml/src/main/scala/org/http4s/scalaxml/ElemInstances.scala +++ b/scala-xml/src/main/scala/org/http4s/scalaxml/ElemInstances.scala @@ -17,7 +17,11 @@ package org.http4s package scalaxml +import cats.data.EitherT +import cats.effect.Async import cats.effect.Concurrent +import cats.syntax.all._ +import fs2.io.toInputStreamResource import org.http4s.Charset.`UTF-8` import org.http4s.headers.`Content-Type` @@ -33,7 +37,7 @@ import scala.xml.XML trait ElemInstances { protected def saxFactory: SAXParserFactory - implicit def xmlEncoder(implicit charset: Charset = `UTF-8`): EntityEncoder.Pure[Elem] = + implicit def xmlEncoder[F[_]](implicit charset: Charset = `UTF-8`): EntityEncoder[F, Elem] = EntityEncoder.stringEncoder .contramap[Elem] { node => val sw = new StringWriter @@ -48,7 +52,8 @@ trait ElemInstances { * * @return an XML element */ - implicit def xml[F[_]](implicit F: Concurrent[F]): EntityDecoder[F, Elem] = { + @deprecated("Blocks. Use xmlDecoder with an Async constraint.", "0.23.12") + def xml[F[_]](implicit F: Concurrent[F]): EntityDecoder[F, Elem] = { import EntityDecoder._ decodeBy(MediaType.text.xml, MediaType.text.html, MediaType.application.xml) { msg => val source = new InputSource() @@ -66,4 +71,25 @@ trait ElemInstances { } } } + + implicit def xmlDecoder[F[_]](implicit F: Async[F]): EntityDecoder[F, Elem] = { + import EntityDecoder._ + decodeBy(MediaType.text.xml, MediaType.text.html, MediaType.application.xml) { msg => + val source = new InputSource() + msg.charset.foreach(cs => source.setEncoding(cs.nioCharset.name)) + + EitherT( + toInputStreamResource(msg.body) + .use { in => + source.setByteStream(in) + val saxParser = saxFactory.newSAXParser() + F.blocking(XML.loadXML(source, saxParser)) + } + .map(Either.right[DecodeFailure, Elem](_)) + .recover { case e: SAXParseException => + Left(MalformedMessageBodyFailure("Invalid XML", Some(e))) + } + ) + } + } } diff --git a/scala-xml/src/test/scala/org/http4s/scalaxml/ScalaXmlSuite.scala b/scala-xml/src/test/scala/org/http4s/scalaxml/ScalaXmlSuite.scala index fade0e7..868696e 100644 --- a/scala-xml/src/test/scala/org/http4s/scalaxml/ScalaXmlSuite.scala +++ b/scala-xml/src/test/scala/org/http4s/scalaxml/ScalaXmlSuite.scala @@ -24,21 +24,23 @@ import fs2.Stream import fs2.text.decodeWithCharset import fs2.text.utf8 import munit.CatsEffectSuite -import munit.ScalaCheckSuite +import munit.ScalaCheckEffectSuite import org.http4s.Status.Ok import org.http4s.headers.`Content-Type` import org.http4s.laws.discipline.arbitrary._ import org.scalacheck.Prop._ +import org.scalacheck.effect.PropF._ import org.typelevel.ci._ +import org.typelevel.scalacheck.xml.generators._ import java.nio.charset.StandardCharsets import scala.xml.Elem -class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { +class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckEffectSuite with ScalaXmlSuiteVersion { def getBody(body: EntityBody[IO]): IO[String] = body.through(utf8.decode).foldMonoid.compile.lastOrError - def strEntity(body: String): Entity[IO] = Entity(Stream(body).through(utf8.encode)) + def strBody(body: String): EntityBody[IO] = Stream(body).through(utf8.encode) def writeToString[A](a: A)(implicit W: EntityEncoder[IO, A]): IO[String] = Stream @@ -56,17 +58,19 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { } } - test("xml should parse the XML") { - server(Request[IO](entity = strEntity("

h1

"))) - .flatMap(r => getBody(r.body)) - .assertEquals("html") + test("round trips utf-8") { + forAllF(genXml) { (elem: Elem) => + val normalized = normalize(elem).asInstanceOf[Elem] + Request[IO]() + .withEntity(normalized) + .as[Elem] + .assertEquals(normalized) + } } test("parse XML in parallel") { - val req = Request(entity = - strEntity( - """

h1

""" - ) + val req = Request[IO]().withEntity( + strBody("""

h1

""") ) // https://github.com/http4s/http4s/issues/1209 (0 to 5).toList @@ -79,8 +83,8 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { } test("return 400 on parse error") { - val entity = strEntity("This is not XML.") - val tresp = server(Request[IO](entity = entity)) + val body = strBody("This is not XML.") + val tresp = server(Request[IO]().withEntity(body)) tresp.map(_.status).assertEquals(Status.BadRequest) } @@ -96,8 +100,8 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { test("encode to UTF-8") { val hello = - assertEquals( - xmlEncoder(Charset.`UTF-8`) + assertIO( + xmlEncoder[IO](Charset.`UTF-8`) .toEntity(hello) .body .through(fs2.text.utf8.decode) @@ -111,10 +115,10 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { test("encode to UTF-16") { val hello = assertIO( - xmlEncoder(Charset.`UTF-16`) + xmlEncoder[IO](Charset.`UTF-16`) .toEntity(hello) .body - .through(decodeWithCharset[IO](StandardCharsets.UTF_16)) + .through(decodeWithCharset(StandardCharsets.UTF_16)) .compile .string, """ @@ -125,10 +129,10 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { test("encode to ISO-8859-1") { val hello = assertIO( - xmlEncoder(Charset.`ISO-8859-1`) + xmlEncoder[IO](Charset.`ISO-8859-1`) .toEntity(hello) .body - .through(decodeWithCharset[IO](StandardCharsets.ISO_8859_1)) + .through(decodeWithCharset(StandardCharsets.ISO_8859_1)) .compile .string, """ @@ -138,7 +142,7 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { property("encoder sets charset of Content-Type") { forAll { (cs: Charset) => - assertEquals(xmlEncoder(cs).headers.get[`Content-Type`].flatMap(_.charset), Some(cs)) + assertEquals(xmlEncoder[IO](cs).headers.get[`Content-Type`].flatMap(_.charset), Some(cs)) } } @@ -253,7 +257,7 @@ class ScalaXmlSuite extends CatsEffectSuite with ScalaCheckSuite { "iso-2022-kr" ) ), - "application/xml; charset=iso-2022kr", + "application/xml; charset=iso-2022-kr", "문재인", ) }