Add autodocodec-http-api-data for form-urlencoded data Derives `Web.FormUrlEncoded.ToForm` and `FromForm` from a `HasObjectCodec` instance, so a type with a codec can be posted as `application/x-www-form-urlencoded` without a second, hand-written description of its fields. ## Why The motivating case is an API endpoint that a shell should be able to hit with curl alone: ``` curl --netrc --silent --show-error --fail-with-body \ --data-urlencode "clone-url=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" \ --data-urlencode "message=$COMMIT_MESSAGE" \ https://api.nix-ci.com/suite ``` The point of the form is that `--data-urlencode` escapes each value, so a commit message containing a quote or a newline needs no JSON encoder in the shell. The type already had a `HasObjectCodec` instance naming all its fields, but with no autodocodec support the `FromForm` instance had to be written by hand, so the field names were spelled twice and a test had to assert the two lists agreed. That test is exactly the check this package makes unnecessary. `http-api-data`'s own generic derivation is not a substitute: it derives names from Haskell record fields via a label modifier, so it would be a third independent spelling of the same names rather than a shared one. ## What `autodocodec-servant-multipart`'s interpreter retargeted at `Web.FormUrlEncoded.Form`, which is likewise a flat map from text keys to text values. A `Form`'s `HashMap Text [Text]` models repeated keys natively, so `ArrayOfCodec` fits it better than it fits multipart's flat `[Input]`. Three places where copying multipart verbatim would have been wrong: - **`unionForm`, not `<>`.** `Form` newtype-derives `Semigroup` from `HashMap`, whose union is left-biased, so `<>` would silently drop the second form's values under a shared key — where multipart's `mappendMultipartData` concatenates. - **Booleans encode lower case.** Multipart's encoder emits Haskell spelling; `toUrlPiece @Bool` is `T.toLower . show`, so `"True"` would round-trip through our own decoder but read as unconventional to every other form consumer. The decoder still accepts either spelling. - **`key=` is a decoder setting.** A form has no null, so whether an empty value is the empty string or is absence is a real choice, and it is `FormDecodeSettings`: ```haskell data EmptyValue = EmptyValueIsValue | EmptyValueIsAbsent ``` The decoder consults it **at optional keys only**. A required key has no absence for an empty value to mean, so it decodes the empty string either way. The default is `EmptyValueIsValue`, which is lossless and agrees with `lookupMaybe`. `EmptyValueIsAbsent` is for the shell case, where `--data-urlencode "x=$UNSET"` sends `x=`. That required/optional distinction exists nowhere but inside the interpreter, which is why the setting lives there rather than in a pre-pass over the `Form` — a pre-pass would strip a required field's legitimate empty string along with the optional field's unset one. Nested values keep multipart's behaviour — JSON-encoded into the text slot — documented in the module header as something no other form parser will understand. ## Tests `autodocodec-api-usage/test/Autodocodec/FormUrlEncodedSpec.hs` mirrors `MultipartSpec`: "matches the encoding", "matches the decoding" and a round trip, over `Example`, `Via`, `LegacyValue`, `LegacyObject`, `These`, `Expression`, `ListsExample` and `Overlap`. Hand-written `ToForm`/`FromForm Example` in `Usage.hs` give the first two something independent to compare against, as the multipart pair do. Beyond that mirror: - Two assertions on a key that two fields share, which is the only shape that tells a values-concatenating union from a left-biased one. Without them, replacing `unionForm` with `<>` passed the entire suite. - Four assertions on `formDecodeSettingEmptyValue`, using `ListsExample` because it has a required and an optional field in one type. The load-bearing one is that under `EmptyValueIsAbsent` a form with *both* fields present and empty still decodes the required field as `"" :| []` while the optional one becomes `Nothing`. I checked that this fails against a pre-pass implementation, which loses the required key with `Left "Expected a nonempty list, but got an empty list."`. - A round trip through `urlEncodeFormStable`/`urlDecodeForm`, so the actual percent-escaped wire bytes are covered. That escaping is the reason the feature exists. **No `xdescribe` was needed.** `MultipartSpec` skips `Example` with "does not hold."; here the whole corpus round-trips, `Example` included. I ran 480,000 examples per property rather than trusting the default 100 before concluding that. Grouping repeated keys is what makes the difference. ## Checks - `nix flake check` passes. The new package builds under `-Werror` against nixpkgs 26.05, 25.11 and 25.05 plus horizon-advance, so against four `http-api-data` versions. - Full `autodocodec-api-usage` suite: 77,483 examples, 0 failures. - `pre-commit run -a` clean. `stack build --pedantic` cannot pass in this tree, and not because of this change: `autodocodec-swagger2` has an unused `aeson` dependency that `-Wunused-packages` rejects under `-Werror`. I confirmed that on a clean tree before working around it. It does not affect `nix flake check`, whose override does not enable that warning, but it does mean the cheapest feedback loop is unusable until a separate one-line fix to `autodocodec-swagger2/package.yaml`. ## Not in this PR `cabal.project` needs no change (it globs `*/*.cabal`); `stack.yaml` does, since it lists packages explicitly. Replacing the first hand-written caller lives in the nix-ci tree, on branch `openapi-spec` (NorfairKing/nix-ci#463), which can now use `fromFormViaCodecWith` with `EmptyValueIsAbsent` instead of its hand-written instance. ## Known limitations Documented in the module header rather than worked around: - A nested object or array is JSON-encoded into the text slot of its key. It round-trips here, but no other form parser will understand it. - An optional field holding an empty list decodes as absent, not as the empty list: a form cannot carry a key with zero values, so `Just []` and `Nothing` encode identically. Same in `autodocodec-servant-multipart`. Use a required field or a non-empty list to tell them apart. - Under `EmptyValueIsAbsent`, an optional field that is genuinely the empty string is inexpressible. There is a test stating that loss rather than skipping the case. ## Where the coverage actually is Worth knowing when reading the spec: for the seven types that derive via `Autodocodec`, "matches the encoding" and "matches the decoding" compare `toForm`/`fromForm` against the very functions those instances delegate to, so they are tautologies. I confirmed this by making the encoder mangle every required key — `Example`'s "matches the encoding" failed, every other type's comparisons passed, and every round trip failed. The structure is inherited from `MultipartSpec` and kept for maintainability, but it means the real coverage is the round trips plus `Example`'s hand-written `ToForm`/`FromForm`. Those are therefore built without the interpreter's own `singletonForm`/`unionForm` helpers, so the comparison is two independent spellings rather than one shared implementation.