{"id":19745653,"url":"https://github.com/lowmelvin/formify-scala","last_synced_at":"2025-04-30T07:34:39.979Z","repository":{"id":183484214,"uuid":"670232565","full_name":"lowmelvin/formify-scala","owner":"lowmelvin","description":"Convert case classes to form data automatically (e.g., for Stripe API)","archived":false,"fork":false,"pushed_at":"2024-11-07T17:08:25.000Z","size":158,"stargazers_count":12,"open_issues_count":2,"forks_count":2,"subscribers_count":2,"default_branch":"master","last_synced_at":"2024-11-07T18:23:21.757Z","etag":null,"topics":["data-conversion","form-data","forms","http4s","scala","scala-3","stripe","stripe-api","url-form","x-www-form-urlencoded"],"latest_commit_sha":null,"homepage":"","language":"Scala","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lowmelvin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-07-24T15:24:57.000Z","updated_at":"2024-11-07T17:08:30.000Z","dependencies_parsed_at":null,"dependency_job_id":"3c68bd34-284b-4ce7-ad23-d40b279266f5","html_url":"https://github.com/lowmelvin/formify-scala","commit_stats":null,"previous_names":["lowmelvin/formify-scala"],"tags_count":5,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lowmelvin%2Fformify-scala","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lowmelvin%2Fformify-scala/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lowmelvin%2Fformify-scala/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lowmelvin%2Fformify-scala/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lowmelvin","download_url":"https://codeload.github.com/lowmelvin/formify-scala/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":224202851,"owners_count":17272807,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["data-conversion","form-data","forms","http4s","scala","scala-3","stripe","stripe-api","url-form","x-www-form-urlencoded"],"created_at":"2024-11-12T02:10:42.841Z","updated_at":"2024-11-12T02:10:43.560Z","avatar_url":"https://github.com/lowmelvin.png","language":"Scala","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Formify-Scala\n\nFormify is a Scala 3 utility library built to convert\ncase classes into the `x-www-form-urlencoded` data format.\nThis format is sometimes required by various APIs (notably\nthe [Stripe API](https://stripe.com/docs/api)\nand [Twilio API](https://www.twilio.com/docs/usage/api))\nwhen transmitting data. This library offers a simple\nmethod of transforming your algebraic data types into strings\ncompliant with this content type.\n\n```scala\nlibraryDependencies += \"com.melvinlow\" %% \"formify\" % \u003cversion\u003e\n```\n\n## Background\n\nTo better understand the functionality of this library, let's take a look at\na sample Stripe API request and payload, as per the official documentation:\n\n```curl\ncurl https://api.stripe.com/v1/checkout/sessions \\\n  -u sk_test_4eC39HqLyjWDarjtT1zdp7dc: \\\n  --data-urlencode success_url=\"https://example.com/success\" \\\n  -d \"line_items[0][price]\"=price_H5ggYwtDq4fbrJ \\\n  -d \"line_items[0][quantity]\"=2 \\\n  -d mode=payment\n```\n\nThis payload can be modeled in Scala as follows:\n\n```scala\nfinal case class Payload(line_items: List[LineItem], mode: String)\nfinal case class LineItem(price: String, quantity: Int)\n\nval data = Payload(List(LineItem(\"price_H5ggYwtDq4fbrJ\", 2)), \"payment\")\n```\n\nFormify facilitates the transformation of such a representation back to its original form:\n\n\n```scala\nFormDataEncoder.encode(data).compile.toList\n// res0: List[Tuple2[String, String]] = List(\n//   (\"line_items[0][price]\", \"price_H5ggYwtDq4fbrJ\"),\n//   (\"line_items[0][quantity]\", \"2\"),\n//   (\"mode\", \"payment\")\n// )\n\nFormDataEncoder.encode(data).serialize\n// res1: String = \"line_items%5B0%5D%5Bprice%5D=price_H5ggYwtDq4fbrJ\u0026line_items%5B0%5D%5Bquantity%5D=2\u0026mode=payment\"\n```\n\nThe compiled version (before `.toList`) is a `Chain[(String, String)]`,\nwhich can be passed directly to http4s's `UrlForm`.\n\n## Basic Usage\n\nStart by including the following imports:\n\n```scala\nimport com.melvinlow.formify.*\nimport com.melvinlow.formify.instances.auto.given\n```\n\nFollowing this, you need to provide a method to merge\nnested field names into a single string\nusing a `FormFieldComposer`. This step is necessary\nbecause `x-www-form-urlencoded` payloads lack a standard approach for this.\n\nHere is an example of accomplishing this for Stripe's API,\nwhere each field name not at the top level is enclosed within brackets \"[]\":\n\n```scala\ngiven FormFieldComposer = FormFieldComposer.make { fragments =\u003e\n  fragments.head + fragments.tail.map(f =\u003e s\"[$f]\").toList.mkString\n}\n```\n\nThat's it! With the prior auto imports, you're now equipped to automatically convert your ADTs:\n\n```scala\nfinal case class Cat(owner: Option[String], favorite_foods: Array[String])\n\nval mirai = Cat(None, Array(\"sushi\", \"taco bell\"))\n// mirai: Cat = Cat(owner = None, favorite_foods = Array(\"sushi\", \"taco bell\"))\n\nFormDataEncoder.encode(mirai).compile.toList\n// res3: List[Tuple2[String, String]] = List(\n//   (\"favorite_foods[0]\", \"sushi\"),\n//   (\"favorite_foods[1]\", \"taco bell\")\n// )\n```\n\nFinally, if you'd like, you can import the `syntax` package to\ngain access to the `asFormData` extension shortcut:\n\n```scala\nimport com.melvinlow.formify.syntax.all.*\n\nmirai.asFormData.compile.toList\n// res4: List[Tuple2[String, String]] = List(\n//   (\"favorite_foods[0]\", \"sushi\"),\n//   (\"favorite_foods[1]\", \"taco bell\")\n// )\n```\n\n## Typeclasses and Extensions\n\nBesides the `FormFieldComposer`, there are two important\ntypeclasses for encoding custom types like `java.time.Instant`.\n\n### FormValueEncoder[T]\n\nThe `FormValueEncoder[T]` typeclass converts\nthe leaf nodes of your ADT into form values. If you're working with\na custom type such as `java.time.Instant`, it's likely you'll\nneed to provide your own instance of this typeclass.\n\nA `Contravariant` typeclass instance from cats is provided,\nmeaning you only need to determine how to\nconvert your custom type to a type already\nsupported by a `FormValueEncoder` instance. For instance, `java.time.Instant` could be encoded into\nepoch seconds by contramapping it to a `Long`:\n\n```scala\nimport cats.syntax.all.*\nimport java.time.Instant\n\ngiven FormValueEncoder[Instant] = FormValueEncoder[Long].contramap(_.getEpochSecond)\n```\n\nAfter this, you can conveniently use `java.time.Instant` in your ADTs:\n\n```scala\nfinal case class Person(created_at: Instant)\n\nval jay = Person(Instant.now)\n// jay: Person = Person(created_at = 2023-08-07T06:16:50.648187Z)\n\nFormDataEncoder.encode(jay).compile.toList\n// res5: List[Tuple2[String, String]] = List((\"created_at\", \"1691389010\"))\n```\n\n### FormDataEncoder[T]\n\nThe `FormDataEncoder[T]` typeclass is the one you've\nbeen interacting with from the beginning. It is responsible for converting\nthe non-leaf, branch parts of your ADT. If you need to generate\nfield names rather than just values, you will need to provide\ntwo instances of this typeclass: one for when your branch type\ncontains leaf nodes and another for when it contains another branch.\n\nOnce again, a `Contravariant` typeclass instance from cats is provided,\nso you simply need to convert your custom type to a type that already\nhas a `FormDataEncoder` instance. Generally, this\nwill be a `Map[String, T]` or a `List[T]`, depending on whether\nyour custom data type has named fields or not.\n\nFor example, you could encode a `Set[T]` by sorting\nits elements first and then converting it into a `List[T]`:\n\n```scala\n// When T is a branch node\ngiven [T: Ordering: FormDataEncoder]: FormDataEncoder[Set[T]] =\n  FormDataEncoder[List[T]].contramap(_.toList.sorted)\n\n// When T is a leaf node\ngiven [T: Ordering: FormValueEncoder]: FormDataEncoder[Set[T]] =\n  FormDataEncoder[List[T]].contramap(_.toList.sorted)\n```\n\nAfter this, you can use `Set[T]` in your ADTs:\n\n```scala\nfinal case class Puppy(favorite_words: Set[String])\n\nval aya = Puppy(Set(\"woof\", \"wan\", \"bark\", \"bitcoin\"))\n// aya: Puppy = Puppy(favorite_words = Set(\"woof\", \"wan\", \"bark\", \"bitcoin\"))\n\nFormDataEncoder.encode(aya).compile.toList\n// res6: List[Tuple2[String, String]] = List(\n//   (\"favorite_words[0]\", \"bark\"),\n//   (\"favorite_words[1]\", \"bitcoin\"),\n//   (\"favorite_words[2]\", \"wan\"),\n//   (\"favorite_words[3]\", \"woof\")\n// )\n```\n\n## Important Considerations\n\nIn practice, a type can serve both as a branch node\nand a leaf node. For instance, you might\nwant to encode a `List` as a `String` instead of indexing into it.\n\nTo manage this ambiguity, the auto derivation implementation\ngives priority to `FormValueEncoder` over `FormDataEncoder`.\nYou can thus manually prompt the auto derivation to halt and\nencode your type as a leaf node (instead of further recursion)\nby providing a `FormValueEncoder` instance for it.\n\nAs an example, let's force a `List[Int]` to be encoded as a `String`\nwhile keeping the default behavior for `List[String]`:\n\n```scala\ngiven FormValueEncoder[List[Int]] =\n  FormValueEncoder[String].contramap(_.mkString(\" and \"))\n\nfinal case class Bird(ages: List[Int], colors: List[String])\n\nval mai = Bird(List(1, 2, 3), List(\"red\", \"blue\", \"green\"))\n// mai: Bird = Bird(\n//   ages = List(1, 2, 3),\n//   colors = List(\"red\", \"blue\", \"green\")\n// )\n\nFormDataEncoder.encode(mai).compile.toList\n// res7: List[Tuple2[String, String]] = List(\n//   (\"ages\", \"1 and 2 and 3\"),\n//   (\"colors[0]\", \"red\"),\n//   (\"colors[1]\", \"blue\"),\n//   (\"colors[2]\", \"green\")\n// )\n```\n\nFinally, it's worth noting that `x-www-form-urlencoded` payloads\nare fundamentally just key-value pairs. As such, there\nis no inherent notion of nesting. However, the definition\nof `FormData` underneath the hood is just\na `Chain[(NonEmptyChain[String], Option[String])]` (i.e., key-value pairs),\nwhich means that it is flexible enough to support any encoding scheme.\n\nSimply go through `instances.scala` and\n[import](https://docs.scala-lang.org/scala3/reference/contextual/given-imports.html)\nwhatever default converters you need. You can then implement\nthe rest as you see fit.\n\n## FAQ\n\n### What does `compile` do?\n\nAs mentioned earlier, `FormData` is really just an opaque type alias to\n`Chain[(NonEmptyChain[String], Option[String])]`. The\n`NonEmptyChain[String]` represents a field name that is possibly split\ninto fragments due to nesting, while the `Option[String]` represents a\nvalue which may or may not exist.\n\nThe `compile` method simply converts this type into a `Chain[(String, String)]`\nby merging the fragments using the provided `FormFieldComposer`\nand discarding any fields with missing values.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flowmelvin%2Fformify-scala","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flowmelvin%2Fformify-scala","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flowmelvin%2Fformify-scala/lists"}