Formed 3

Helper library to squash generic nested products into a list of fields for www-form-urlencoded requests, typically to interface with APIs that don't support JSON.

For Scala 3.

Quick Usage

Add to your build.sbt:

libraryDependencies += "io.github.rzqx" % "formed3" % "<version>"

Imports:

import io.github.rzqx.formed.implicits.*
import io.github.rzqx.formed.syntax.*

Define your ADT (using Stripe's API as an example) and create an instance:

final case class LineItem(price: String, quantity: Int)
final case class CheckoutSession(mode: String, line_items: List[LineItem])

val item1 = LineItem("price1", 1)
val item2 = LineItem("price2", 2)
val checkout = CheckoutSession("payment", List(item1, item2))

Squash the instance:

checkout.asFormData
// res0: Chain[Tuple2[String, String]] = Wrap(
//   seq = Vector(
//     ("mode", "payment"),
//     ("line_items[0][price]", "price1"),
//     ("line_items[0][quantity]", "1"),
//     ("line_items[1][price]", "price2"),
//     ("line_items[1][quantity]", "2")
//   )
// )

checkout.asFormUrlEncoded
// res1: String = "mode%3Dpayment%26line_items%5B0%5D%5Bprice%5D%3Dprice1%26line_items%5B0%5D%5Bquantity%5D%3D1%26line_items%5B1%5D%5Bprice%5D%3Dprice2%26line_items%5B1%5D%5Bquantity%5D%3D2"

Use in http4s:

import org.http4s.UrlForm

UrlForm.fromChain(checkout.asFormData)
// res2: UrlForm = HashMap(line_items[0][quantity] -> Chain(1), line_items[1][price] -> Chain(price2), line_items[0][price] -> Chain(price1), mode -> Chain(payment), line_items[1][quantity] -> Chain(2))

Customization

Define an encoder for a new type by converting it into a string inside contramap:

import io.github.rzqx.formed.FormEncoder
import cats.implicits.*
import scala.concurrent.duration.*

implicit val durationEncoder: FormEncoder[Duration] =
  FormEncoder[String].contramap(_.toSeconds.toString)
// durationEncoder: FormEncoder[Duration] = io.github.rzqx.formed.FormEncoder$$anon$1$$Lambda$11087/0x0000000802c14e80@1d873435
  
final case class Foo(duration: Duration)

Foo(1.hour).asFormDisplay 
// res3: String = "duration=3600"

Define a custom prefix encoder to change the way nested fields are encoded:

import io.github.rzqx.formed.PrefixEncoder

// import only the encoder instances
import io.github.rzqx.formed.instances.EncoderInstances.*

import io.github.rzqx.formed.syntax.*
import cats.data.Chain
import cats.implicits.*

implicit val arrowPrefixEncoder: PrefixEncoder = (value: Chain[String]) =>
  value.deleteFirst(_ => true) match {
    case Some((head, tail)) => head + tail.foldMap(v => s"->$v")
    case None => ""
  }

Use the custom prefix encoder:

final case class LineItem(price: String, quantity: Int)
final case class CheckoutSession(mode: String, line_items: List[LineItem])

CheckoutSession("payment", List(LineItem("price1", 1))).asFormDisplay
// res5: String = """mode=payment
// line_items->0->price=price1
// line_items->0->quantity=1"""