Skip to content

Commit

Permalink
Merge pull request #140 from ubaldop/json-support
Browse files Browse the repository at this point in the history
Add support to JSON encoding and decoding
  • Loading branch information
ybasket authored Jan 25, 2024
2 parents daea701 + 96bccaa commit 005abdf
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 6 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,11 @@ jobs:

- 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 target .js/target cbor/.js/target site/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 .jvm/target csv/.js/target .native/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 target .js/target cbor/.js/target site/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 .jvm/target csv/.js/target .native/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')
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 19 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ThisBuild / tlBaseVersion := "0.2"
ThisBuild / tlBaseVersion := "0.3"
ThisBuild / developers := List(
tlGitHubDev("rossabaker", "Ross A. Baker"),
tlGitHubDev("ybasket", "Yannick Heiber"),
Expand Down Expand Up @@ -66,7 +66,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,
Expand All @@ -85,7 +84,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,
Expand All @@ -96,9 +94,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",
Expand Down
55 changes: 55 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
66 changes: 66 additions & 0 deletions json/src/main/scala/org/http4s/fs2data/json/JsonInstances.scala
Original file line number Diff line number Diff line change
@@ -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)

}
19 changes: 19 additions & 0 deletions json/src/main/scala/org/http4s/fs2data/json/json.scala
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions json/src/test/scala/org/http4s/fs2data/json/JsonEventSuite.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}

0 comments on commit 005abdf

Please sign in to comment.