git.iko.soy/typeable/http-api-data
on master help sign in
.github/workflows/haskell-ci.ymlnew file+283
since splitsnapshots
unifiedsplit
@@ -0,0 +1,283 @@
1+# This GitHub workflow config has been generated by a script via
2+#
3+# haskell-ci 'github' 'cabal.project'
4+#
5+# To regenerate the script (for example after adjusting tested-with) run
6+#
7+# haskell-ci regenerate
8+#
9+# For more information, see https://github.com/haskell-CI/haskell-ci
10+#
11+# version: 0.15.20220609
12+#
13+# REGENDATA ("0.15.20220609",["github","cabal.project"])
14+#
15+name: Haskell-CI
16+on:
17+ push:
18+ branches:
19+ - master
20+ pull_request:
21+ branches:
22+ - master
23+jobs:
24+ linux:
25+ name: Haskell-CI - Linux - ${{ matrix.compiler }}
26+ runs-on: ubuntu-20.04
27+ timeout-minutes:
28+ 60
29+ container:
30+ image: buildpack-deps:bionic
31+ continue-on-error: ${{ matrix.allow-failure }}
32+ strategy:
33+ matrix:
34+ include:
35+ - compiler: ghc-9.4.0.20220523
36+ compilerKind: ghc
37+ compilerVersion: 9.4.0.20220523
38+ setup-method: ghcup
39+ allow-failure: true
40+ - compiler: ghc-9.2.3
41+ compilerKind: ghc
42+ compilerVersion: 9.2.3
43+ setup-method: ghcup
44+ allow-failure: false
45+ - compiler: ghc-9.0.2
46+ compilerKind: ghc
47+ compilerVersion: 9.0.2
48+ setup-method: ghcup
49+ allow-failure: false
50+ - compiler: ghc-8.10.7
51+ compilerKind: ghc
52+ compilerVersion: 8.10.7
53+ setup-method: ghcup
54+ allow-failure: false
55+ - compiler: ghc-8.8.4
56+ compilerKind: ghc
57+ compilerVersion: 8.8.4
58+ setup-method: hvr-ppa
59+ allow-failure: false
60+ - compiler: ghc-8.6.5
61+ compilerKind: ghc
62+ compilerVersion: 8.6.5
63+ setup-method: hvr-ppa
64+ allow-failure: false
65+ - compiler: ghc-8.4.4
66+ compilerKind: ghc
67+ compilerVersion: 8.4.4
68+ setup-method: hvr-ppa
69+ allow-failure: false
70+ - compiler: ghc-8.2.2
71+ compilerKind: ghc
72+ compilerVersion: 8.2.2
73+ setup-method: hvr-ppa
74+ allow-failure: false
75+ - compiler: ghc-8.0.2
76+ compilerKind: ghc
77+ compilerVersion: 8.0.2
78+ setup-method: hvr-ppa
79+ allow-failure: false
80+ fail-fast: false
81+ steps:
82+ - name: apt
83+ run: |
84+ apt-get update
85+ apt-get install -y --no-install-recommends gnupg ca-certificates dirmngr curl git software-properties-common libtinfo5
86+ if [ "${{ matrix.setup-method }}" = ghcup ]; then
87+ mkdir -p "$HOME/.ghcup/bin"
88+ curl -sL https://downloads.haskell.org/ghcup/0.1.17.8/x86_64-linux-ghcup-0.1.17.8 > "$HOME/.ghcup/bin/ghcup"
89+ chmod a+x "$HOME/.ghcup/bin/ghcup"
90+ if $HEADHACKAGE; then "$HOME/.ghcup/bin/ghcup" config add-release-channel https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-prereleases-0.0.7.yaml; fi
91+ "$HOME/.ghcup/bin/ghcup" install ghc "$HCVER" || (cat "$HOME"/.ghcup/logs/*.* && false)
92+ "$HOME/.ghcup/bin/ghcup" install cabal 3.6.2.0 || (cat "$HOME"/.ghcup/logs/*.* && false)
93+ else
94+ apt-add-repository -y 'ppa:hvr/ghc'
95+ apt-get update
96+ apt-get install -y "$HCNAME"
97+ mkdir -p "$HOME/.ghcup/bin"
98+ curl -sL https://downloads.haskell.org/ghcup/0.1.17.8/x86_64-linux-ghcup-0.1.17.8 > "$HOME/.ghcup/bin/ghcup"
99+ chmod a+x "$HOME/.ghcup/bin/ghcup"
100+ "$HOME/.ghcup/bin/ghcup" install cabal 3.6.2.0 || (cat "$HOME"/.ghcup/logs/*.* && false)
101+ fi
102+ env:
103+ HCKIND: ${{ matrix.compilerKind }}
104+ HCNAME: ${{ matrix.compiler }}
105+ HCVER: ${{ matrix.compilerVersion }}
106+ - name: Set PATH and environment variables
107+ run: |
108+ echo "$HOME/.cabal/bin" >> $GITHUB_PATH
109+ echo "LANG=C.UTF-8" >> "$GITHUB_ENV"
110+ echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV"
111+ echo "CABAL_CONFIG=$HOME/.cabal/config" >> "$GITHUB_ENV"
112+ HCDIR=/opt/$HCKIND/$HCVER
113+ if [ "${{ matrix.setup-method }}" = ghcup ]; then
114+ HC=$HOME/.ghcup/bin/$HCKIND-$HCVER
115+ echo "HC=$HC" >> "$GITHUB_ENV"
116+ echo "HCPKG=$HOME/.ghcup/bin/$HCKIND-pkg-$HCVER" >> "$GITHUB_ENV"
117+ echo "HADDOCK=$HOME/.ghcup/bin/haddock-$HCVER" >> "$GITHUB_ENV"
118+ echo "CABAL=$HOME/.ghcup/bin/cabal-3.6.2.0 -vnormal+nowrap" >> "$GITHUB_ENV"
119+ else
120+ HC=$HCDIR/bin/$HCKIND
121+ echo "HC=$HC" >> "$GITHUB_ENV"
122+ echo "HCPKG=$HCDIR/bin/$HCKIND-pkg" >> "$GITHUB_ENV"
123+ echo "HADDOCK=$HCDIR/bin/haddock" >> "$GITHUB_ENV"
124+ echo "CABAL=$HOME/.ghcup/bin/cabal-3.6.2.0 -vnormal+nowrap" >> "$GITHUB_ENV"
125+ fi
126+
127+ HCNUMVER=$(${HC} --numeric-version|perl -ne '/^(\d+)\.(\d+)\.(\d+)(\.(\d+))?$/; print(10000 * $1 + 100 * $2 + ($3 == 0 ? $5 != 1 : $3))')
128+ echo "HCNUMVER=$HCNUMVER" >> "$GITHUB_ENV"
129+ echo "ARG_TESTS=--enable-tests" >> "$GITHUB_ENV"
130+ echo "ARG_BENCH=--enable-benchmarks" >> "$GITHUB_ENV"
131+ if [ $((HCNUMVER >= 90400)) -ne 0 ] ; then echo "HEADHACKAGE=true" >> "$GITHUB_ENV" ; else echo "HEADHACKAGE=false" >> "$GITHUB_ENV" ; fi
132+ echo "ARG_COMPILER=--$HCKIND --with-compiler=$HC" >> "$GITHUB_ENV"
133+ echo "GHCJSARITH=0" >> "$GITHUB_ENV"
134+ env:
135+ HCKIND: ${{ matrix.compilerKind }}
136+ HCNAME: ${{ matrix.compiler }}
137+ HCVER: ${{ matrix.compilerVersion }}
138+ - name: env
139+ run: |
140+ env
141+ - name: write cabal config
142+ run: |
143+ mkdir -p $CABAL_DIR
144+ cat >> $CABAL_CONFIG <<EOF
145+ remote-build-reporting: anonymous
146+ write-ghc-environment-files: never
147+ remote-repo-cache: $CABAL_DIR/packages
148+ logs-dir: $CABAL_DIR/logs
149+ world-file: $CABAL_DIR/world
150+ extra-prog-path: $CABAL_DIR/bin
151+ symlink-bindir: $CABAL_DIR/bin
152+ installdir: $CABAL_DIR/bin
153+ build-summary: $CABAL_DIR/logs/build.log
154+ store-dir: $CABAL_DIR/store
155+ install-dirs user
156+ prefix: $CABAL_DIR
157+ repository hackage.haskell.org
158+ url: http://hackage.haskell.org/
159+ EOF
160+ if $HEADHACKAGE; then
161+ cat >> $CABAL_CONFIG <<EOF
162+ repository head.hackage.ghc.haskell.org
163+ url: https://ghc.gitlab.haskell.org/head.hackage/
164+ secure: True
165+ root-keys: 7541f32a4ccca4f97aea3b22f5e593ba2c0267546016b992dfadcd2fe944e55d
166+ 26021a13b401500c8eb2761ca95c61f2d625bfef951b939a8124ed12ecf07329
167+ f76d08be13e9a61a377a85e2fb63f4c5435d40f8feb3e12eb05905edb8cdea89
168+ key-threshold: 3
169+ active-repositories: hackage.haskell.org, head.hackage.ghc.haskell.org:override
170+ EOF
171+ fi
172+ cat >> $CABAL_CONFIG <<EOF
173+ program-default-options
174+ ghc-options: $GHCJOBS +RTS -M3G -RTS
175+ EOF
176+ cat $CABAL_CONFIG
177+ - name: versions
178+ run: |
179+ $HC --version || true
180+ $HC --print-project-git-commit-id || true
181+ $CABAL --version || true
182+ - name: update cabal index
183+ run: |
184+ $CABAL v2-update -v
185+ - name: cache (tools)
186+ uses: actions/cache@v2
187+ with:
188+ key: ${{ runner.os }}-${{ matrix.compiler }}-tools-445c2459
189+ path: ~/.haskell-ci-tools
190+ - name: install cabal-plan
191+ run: |
192+ mkdir -p $HOME/.cabal/bin
193+ curl -sL https://github.com/haskell-hvr/cabal-plan/releases/download/v0.6.2.0/cabal-plan-0.6.2.0-x86_64-linux.xz > cabal-plan.xz
194+ echo 'de73600b1836d3f55e32d80385acc055fd97f60eaa0ab68a755302685f5d81bc cabal-plan.xz' | sha256sum -c -
195+ xz -d < cabal-plan.xz > $HOME/.cabal/bin/cabal-plan
196+ rm -f cabal-plan.xz
197+ chmod a+x $HOME/.cabal/bin/cabal-plan
198+ cabal-plan --version
199+ - name: install doctest
200+ run: |
201+ if [ $((HCNUMVER < 90000)) -ne 0 ] ; then $CABAL --store-dir=$HOME/.haskell-ci-tools/store v2-install $ARG_COMPILER --ignore-project -j2 doctest --constraint='doctest ^>=0.20' ; fi
202+ if [ $((HCNUMVER < 90000)) -ne 0 ] ; then doctest --version ; fi
203+ - name: checkout
204+ uses: actions/checkout@v2
205+ with:
206+ path: source
207+ - name: initial cabal.project for sdist
208+ run: |
209+ touch cabal.project
210+ echo "packages: $GITHUB_WORKSPACE/source/." >> cabal.project
211+ cat cabal.project
212+ - name: sdist
213+ run: |
214+ mkdir -p sdist
215+ $CABAL sdist all --output-dir $GITHUB_WORKSPACE/sdist
216+ - name: unpack
217+ run: |
218+ mkdir -p unpacked
219+ find sdist -maxdepth 1 -type f -name '*.tar.gz' -exec tar -C $GITHUB_WORKSPACE/unpacked -xzvf {} \;
220+ - name: generate cabal.project
221+ run: |
222+ PKGDIR_http_api_data="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/http-api-data-[0-9.]*')"
223+ echo "PKGDIR_http_api_data=${PKGDIR_http_api_data}" >> "$GITHUB_ENV"
224+ rm -f cabal.project cabal.project.local
225+ touch cabal.project
226+ touch cabal.project.local
227+ echo "packages: ${PKGDIR_http_api_data}" >> cabal.project
228+ if [ $((HCNUMVER >= 80200)) -ne 0 ] ; then echo "package http-api-data" >> cabal.project ; fi
229+ if [ $((HCNUMVER >= 80200)) -ne 0 ] ; then echo " ghc-options: -Werror=missing-methods" >> cabal.project ; fi
230+ cat >> cabal.project <<EOF
231+ EOF
232+ if $HEADHACKAGE; then
233+ echo "allow-newer: $($HCPKG list --simple-output | sed -E 's/([a-zA-Z-]+)-[0-9.]+/*:\1,/g')" >> cabal.project
234+ fi
235+ $HCPKG list --simple-output --names-only | perl -ne 'for (split /\s+/) { print "constraints: $_ installed\n" unless /^(http-api-data)$/; }' >> cabal.project.local
236+ cat cabal.project
237+ cat cabal.project.local
238+ - name: dump install plan
239+ run: |
240+ $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dry-run all
241+ cabal-plan
242+ - name: cache
243+ uses: actions/cache@v2
244+ with:
245+ key: ${{ runner.os }}-${{ matrix.compiler }}-${{ github.sha }}
246+ path: ~/.cabal/store
247+ restore-keys: ${{ runner.os }}-${{ matrix.compiler }}-
248+ - name: install dependencies
249+ run: |
250+ $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --dependencies-only -j2 all
251+ $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dependencies-only -j2 all
252+ - name: build w/o tests
253+ run: |
254+ $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all
255+ - name: build
256+ run: |
257+ $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH all --write-ghc-environment-files=always
258+ - name: tests
259+ run: |
260+ $CABAL v2-test $ARG_COMPILER $ARG_TESTS $ARG_BENCH all --test-show-details=direct
261+ - name: doctest
262+ run: |
263+ perl -i -e 'while (<ARGV>) { print unless /package-id\s+(base-compat)-\d+(\.\d+)*/; }' .ghc.environment.*
264+ if [ $((HCNUMVER < 90000)) -ne 0 ] ; then cd ${PKGDIR_http_api_data} || false ; fi
265+ if [ $((HCNUMVER < 90000)) -ne 0 ] ; then doctest -XHaskell2010 src/ ; fi
266+ - name: cabal check
267+ run: |
268+ cd ${PKGDIR_http_api_data} || false
269+ ${CABAL} -vnormal check
270+ - name: haddock
271+ run: |
272+ $CABAL v2-haddock --haddock-all $ARG_COMPILER --with-haddock $HADDOCK $ARG_TESTS $ARG_BENCH all
273+ - name: unconstrained build
274+ run: |
275+ rm -f cabal.project.local
276+ $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all
277+ - name: prepare for constraint sets
278+ run: |
279+ rm -f cabal.project.local
280+ - name: constraint set text-show
281+ run: |
282+ $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --constraint='http-api-data +use-text-show' --dependencies-only -j2 all
283+ $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --constraint='http-api-data +use-text-show' all
.gitignore+4
@@ -1,5 +1,9 @@
1/dist/
1/dist/
2+/dist-newstyle/
3+/.ghc.environment.*
2/.shelly/
4/.shelly/
3/tarballs/
5/tarballs/
4*.swp
6*.swp
5.stack-work/
7.stack-work/
8+src/highlight.js
9+src/style.css
.travis.ymldeleted−40
@@ -1,40 +0,0 @@
1−env:
2− - GHCVER=7.6.3 CABALVER=1.18 CABAL_FLAGS=
3− - GHCVER=7.8.4 CABALVER=1.18 CABAL_FLAGS=
4− - GHCVER=7.10.2 CABALVER=1.22 CABAL_FLAGS=
5− - GHCVER=head CABALVER=head CABAL_FLAGS=
6− - GHCVER=7.6.3 CABALVER=1.18 CABAL_FLAGS=use-text-show
7− - GHCVER=7.8.4 CABALVER=1.18 CABAL_FLAGS=use-text-show
8− - GHCVER=7.10.2 CABALVER=1.22 CABAL_FLAGS=use-text-show
9− - GHCVER=head CABALVER=head CABAL_FLAGS=use-text-show
10−
11−matrix:
12− allow_failures:
13− - env: GHCVER=head CABALVER=head CABAL_FLAGS=
14− - env: GHCVER=head CABALVER=head CABAL_FLAGS=use-text-show
15−
16−before_install:
17− - travis_retry sudo add-apt-repository -y ppa:hvr/ghc
18− - travis_retry sudo apt-get update
19− - travis_retry sudo apt-get install -y cabal-install-$CABALVER ghc-$GHCVER
20− - export PATH=/opt/ghc/$GHCVER/bin:/opt/cabal/$CABALVER/bin:$HOME/.cabal/bin:$PATH
21− - cabal --version
22−
23−install:
24− - cabal update
25− - cabal install --only-dependencies --enable-tests --flags=$CABAL_FLAGS
26−
27−script:
28− - cabal configure --enable-tests -v2 --flags=$CABAL_FLAGS
29− - cabal build
30− - cabal test
31− - cabal check
32− - cabal sdist
33− - export SRC_TGZ=$(cabal info . | awk '{print $2 ".tar.gz";exit}') ;
34− cd dist/;
35− if [ -f "$SRC_TGZ" ]; then
36− cabal install "$SRC_TGZ";
37− else
38− echo "expected '$SRC_TGZ' not found";
39− exit 1;
40− fi
CHANGELOG.md+150−1
@@ -1,4 +1,153 @@
1+0.5
2+---
3+
4+* Use `attoparsec-iso8601-1.1.0.0`.
5+ `Day` and `UTCTime` parsers require at least 4 digits now, which is a breaking change.
6+* Drop GHC-7.8 and GHC-7.10 support.
7+
8+0.4.3
9+-----
10+
11+* Add `Quarter`, `QuarterOfYear` and `Month` instances
12+* Support `bytestring-0.11`
13+
14+0.4.2
15+-----
16+
17+* Add instances for `Const` and `Identity`
18+
19+0.4.1.1
20+-------
21+
22+* Allow cookie <0.5
23+* Change to `build-type: Simple`
24+
25+0.4.1
26+-----
27+
28+* Use `time-compat` to provide instances for `DayOfWeek`.
29+
30+0.4
31+---
32+
33+* `NominalDiffTime` instances preserve precision (roundtrip)
34+* Add `Semigroup.Min`, `Max`, `First`, `Last` instances
35+* Add `Tagged b a` instances
36+
37+0.3.10
38+---
39+
40+* Fix 'SetCookie' instances
41+ (see [#86](https://github.com/fizruk/http-api-data/pull/86))
42+* Add support for `Fixed`
43+ (see [#78](https://github.com/fizruk/http-api-data/pull/87))
44+
45+0.3.9
46+---
47+
48+* GHC-8.6 support
49+* Remove dependency on `uri-bytestring` and use functions from `http-types` instead
50+ (see [#75](https://github.com/fizruk/http-api-data/pull/75))
51+* Add support for `SetCookie`
52+ (see [#74](https://github.com/fizruk/http-api-data/pull/74))
53+
54+0.3.8.1
55+---
56+
57+* GHC-8.4.1 support
58+
59+0.3.8
60+---
61+
1−Unreleased changes
62+* Minor changes:
63+ * Stable URL-encoding for `Form`s (see [#67](https://github.com/fizruk/http-api-data/pull/67)):
64+ * Introduce `urlEncodeParams` and `urlDecodeParams`;
65+ * Introduce `urlEncodeAsFormStable` and use stable encoding for doctests;
66+ * Add `toEntriesByKeyStable` and `toListStable`;
67+ * Add `Semigroup` instance for `Form` (see [#69](https://github.com/fizruk/http-api-data/pull/69));
68+ * Relax upper bound on Cabal (see [#73](https://github.com/fizruk/http-api-data/pull/73)).
69+
70+0.3.7.2
71+---
72+
73+* Allow http-types-0.12
74+* .cabal file adjustments
75+
76+0.3.7.1
77+---
78+
79+* GHC-8.2 support (see [#55](https://github.com/fizruk/http-api-data/pull/55)).
80+
81+0.3.7
82+---
83+
84+* Minor changes:
85+ * Use [`attoparsec-iso8601`](http://hackage.haskell.org/package/attoparsec-iso8601)
86+ for parsing of time types. Now the accepted formats are the same as by `aeson`,
87+ i.e. parsers are more lenient
88+ (see [#41](https://github.com/fizruk/http-api-data/pull/41));
89+ * Preserve fractions of a second in `ToHttpApiData` instances (see [#53](https://github.com/fizruk/http-api-data/pull/53));
90+ * Add `ToHttpApiData` and `FromHttpApiData` instances for `TimeOfDay` (see [#53](https://github.com/fizruk/http-api-data/pull/53)).
91+
92+0.3.6
93+---
94+
95+* Minor change:
96+ * Add `toEncodedUrlPiece` class method for URL-encoded path segments (see [#50](https://github.com/fizruk/http-api-data/pull/50)); use efficient encoding for types whose values don't need URL-encoding.
97+
98+0.3.5
99+---
100+
101+* Minor changes:
102+ * Add `LenientData` which always succeeds to parse (see [#45](https://github.com/fizruk/http-api-data/pull/45)).
103+
104+0.3.4
105+---
106+
107+* Minor changes:
108+ * Add support for GHC 8.2, drop support for GHC 7.6 (see [#44](https://github.com/fizruk/http-api-data/pull/44)).
109+
110+0.3.3
111+---
112+
113+* Minor changes:
114+ * Expose `Form` constructor from `Web.FromUrlEncoded` (see [#40](https://github.com/fizruk/http-api-data/pull/40));
115+ * Fix example in `FromForm` documentation (see [#39](https://github.com/fizruk/http-api-data/issues/39)).
116+
117+0.3.2
118+---
119+
120+* Minor change:
121+ * Export `Form` type from `Web.FormUrlEncoded` (see [#37](https://github.com/fizruk/http-api-data/pull/37)).
122+
123+0.3.1
124+---
125+
126+* Minor changes:
127+ * Add instances for `Data.UUID` (see [#34](https://github.com/fizruk/http-api-data/pull/34)).
128+
129+0.3
130+---
131+* Major changes:
132+ * Add `Web.FormUrlEncoded` to work with form data (see [#32](https://github.com/fizruk/http-api-data/pull/32)).
133+
134+* Minor changes:
135+ * Add instances for `Numeric.Natural` (see [`d944721`](https://github.com/fizruk/http-api-data/commit/d944721ac94929a7ed9e66f25e23221799c08d83)).
136+
137+0.2.4
138+---
139+* Make `parseHeader` total (instead of throwing exceptions on invalid Unicode, see [#30](https://github.com/fizruk/http-api-data/pull/30)).
140+
141+0.2.3
142+---
143+* Add more parser helpers for `Bounded` `Enum` types.
144+
145+0.2.2
146+---
147+
148+* Add instances for more `time` types: `LocalTime`, `ZonedTime`, `UTCTime` and `NominalDiffTime`
149+
150+0.2.1
2---
151---
3
152
4* Add helpers for multiple URL pieces and query params:
153* Add helpers for multiple URL pieces and query params:
README.md+3−1
@@ -1,7 +1,9 @@
1# http-api-data
1# http-api-data
2
2
3−[![Hackage package](http://img.shields.io/hackage/v/http-api-data.svg)](http://hackage.haskell.org/package/http-api-data)
4[![Build Status](https://secure.travis-ci.org/fizruk/http-api-data.png?branch=master)](http://travis-ci.org/fizruk/http-api-data)
3[![Build Status](https://secure.travis-ci.org/fizruk/http-api-data.png?branch=master)](http://travis-ci.org/fizruk/http-api-data)
4+[![Hackage package](http://img.shields.io/hackage/v/http-api-data.svg)](http://hackage.haskell.org/package/http-api-data)
5+[![Stackage LTS](http://stackage.org/package/http-api-data/badge/lts)](http://stackage.org/lts/package/http-api-data)
6+[![Stackage Nightly](http://stackage.org/package/http-api-data/badge/nightly)](http://stackage.org/nightly/package/http-api-data)
5
7
6This package defines typeclasses used for converting Haskell data types to and from HTTP API data.
8This package defines typeclasses used for converting Haskell data types to and from HTTP API data.
7
9
Setup.hsnew file+2
@@ -0,0 +1,2 @@
1+import Distribution.Simple
2+main = defaultMain
Setup.lhsdeleted−7
@@ -1,7 +0,0 @@
1−#!/usr/bin/env runhaskell
2−
3−> module Main where
4−> import Distribution.Simple
5−
6−> main :: IO ()
7−> main = defaultMain
Web/HttpApiData/Internal.hsdeleted−476
@@ -1,476 +0,0 @@
1−{-# LANGUAGE CPP #-}
2−{-# LANGUAGE DefaultSignatures #-}
3−{-# LANGUAGE FlexibleInstances #-}
4−{-# LANGUAGE OverloadedStrings #-}
5−{-# LANGUAGE ScopedTypeVariables #-}
6−{-# LANGUAGE TypeSynonymInstances #-}
7−-- |
8−-- Convert Haskell values to and from HTTP API data
9−-- such as URL pieces, headers and query parameters.
10−module Web.HttpApiData.Internal where
11−
12−#if __GLASGOW_HASKELL__ < 710
13−import Control.Applicative
14−import Data.Traversable (Traversable(traverse))
15−#endif
16−import Control.Arrow ((&&&))
17−
18−import Data.Monoid
19−import Data.ByteString (ByteString)
20−import qualified Data.ByteString as BS
21−
22−import Data.Int
23−import Data.Word
24−
25−import Data.Text (Text)
26−import Data.Text.Encoding (encodeUtf8, decodeUtf8)
27−import Data.Text.Read (signed, decimal, rational, Reader)
28−import qualified Data.Text as T
29−import qualified Data.Text.Lazy as L
30−
31−import Data.Time
32−import Data.Version
33−
34−#if MIN_VERSION_base(4,8,0)
35−import Data.Void
36−#endif
37−
38−import Text.Read (readMaybe)
39−import Text.ParserCombinators.ReadP (readP_to_S)
40−
41−#if USE_TEXT_SHOW
42−import TextShow (TextShow, showt)
43−#endif
44−
45−-- | Convert value to HTTP API data.
46−class ToHttpApiData a where
47− {-# MINIMAL toUrlPiece | toQueryParam #-}
48− -- | Convert to URL path piece.
49− toUrlPiece :: a -> Text
50− toUrlPiece = toQueryParam
51−
52− -- | Convert to HTTP header value.
53− toHeader :: a -> ByteString
54− toHeader = encodeUtf8 . toUrlPiece
55−
56− -- | Convert to query param value.
57− toQueryParam :: a -> Text
58− toQueryParam = toUrlPiece
59−
60−-- | Parse value from HTTP API data.
61−class FromHttpApiData a where
62− {-# MINIMAL parseUrlPiece | parseQueryParam #-}
63− -- | Parse URL path piece.
64− parseUrlPiece :: Text -> Either Text a
65− parseUrlPiece = parseQueryParam
66−
67− -- | Parse HTTP header value.
68− parseHeader :: ByteString -> Either Text a
69− parseHeader = parseUrlPiece . decodeUtf8
70−
71− -- | Parse query param value.
72− parseQueryParam :: Text -> Either Text a
73− parseQueryParam = parseUrlPiece
74−
75−-- | Convert multiple values to a list of URL pieces.
76−--
77−-- >>> toUrlPieces [1, 2, 3]
78−-- ["1","2","3"]
79−toUrlPieces :: (Functor t, ToHttpApiData a) => t a -> t Text
80−toUrlPieces = fmap toUrlPiece
81−
82−-- | Parse multiple URL pieces.
83−--
84−-- >>> parseUrlPieces ["true", "false"] :: Either Text [Bool]
85−-- Right [True,False]
86−-- >>> parseUrlPieces ["123", "hello", "world"] :: Either Text [Int]
87−-- Left "could not parse: `hello' (input does not start with a digit)"
88−parseUrlPieces :: (Traversable t, FromHttpApiData a) => t Text -> Either Text (t a)
89−parseUrlPieces = traverse parseUrlPiece
90−
91−-- | Convert multiple values to a list of query parameter values.
92−--
93−-- >>> toQueryParams [fromGregorian 2015 10 03, fromGregorian 2015 12 01]
94−-- ["2015-10-03","2015-12-01"]
95−toQueryParams :: (Functor t, ToHttpApiData a) => t a -> t Text
96−toQueryParams = fmap toQueryParam
97−
98−-- | Parse multiple query parameters.
99−--
100−-- >>> parseQueryParams ["1", "2", "3"] :: Either Text [Int]
101−-- Right [1,2,3]
102−-- >>> parseQueryParams ["64", "128", "256"] :: Either Text [Word8]
103−-- Left "out of bounds: `256' (should be between 0 and 255)"
104−parseQueryParams :: (Traversable t, FromHttpApiData a) => t Text -> Either Text (t a)
105−parseQueryParams = traverse parseQueryParam
106−
107−-- | Parse URL path piece in a @'Maybe'@.
108−--
109−-- >>> parseUrlPieceMaybe "12" :: Maybe Int
110−-- Just 12
111−parseUrlPieceMaybe :: FromHttpApiData a => Text -> Maybe a
112−parseUrlPieceMaybe = either (const Nothing) Just . parseUrlPiece
113−
114−-- | Parse HTTP header value in a @'Maybe'@.
115−--
116−-- >>> parseHeaderMaybe "hello" :: Maybe Text
117−-- Just "hello"
118−parseHeaderMaybe :: FromHttpApiData a => ByteString -> Maybe a
119−parseHeaderMaybe = either (const Nothing) Just . parseHeader
120−
121−-- | Parse query param value in a @'Maybe'@.
122−--
123−-- >>> parseQueryParamMaybe "true" :: Maybe Bool
124−-- Just True
125−parseQueryParamMaybe :: FromHttpApiData a => Text -> Maybe a
126−parseQueryParamMaybe = either (const Nothing) Just . parseQueryParam
127−
128−-- | Default parsing error.
129−defaultParseError :: Text -> Either Text a
130−defaultParseError input = Left ("could not parse: `" <> input <> "'")
131−
132−-- | Convert @'Maybe'@ parser into @'Either' 'Text'@ parser with default error message.
133−parseMaybeTextData :: (Text -> Maybe a) -> (Text -> Either Text a)
134−parseMaybeTextData parse input =
135− case parse input of
136− Nothing -> defaultParseError input
137− Just val -> Right val
138−
139−#if USE_TEXT_SHOW
140−-- | /Lower case/.
141−--
142−-- Convert to URL piece using @'TextShow'@ instance.
143−-- The result is always lower cased.
144−--
145−-- >>> showTextData True
146−-- "true"
147−--
148−-- This can be used as a default implementation for enumeration types:
149−--
150−-- @
151−-- data MyData = Foo | Bar | Baz deriving (Generic)
152−--
153−-- instance TextShow MyData where
154−-- showt = genericShowt
155−--
156−-- instance ToHttpApiData MyData where
157−-- toUrlPiece = showTextData
158−-- @
159−showTextData :: TextShow a => a -> Text
160−showTextData = T.toLower . showt
161−#else
162−-- | /Lower case/.
163−--
164−-- Convert to URL piece using @'Show'@ instance.
165−-- The result is always lower cased.
166−--
167−-- >>> showTextData True
168−-- "true"
169−--
170−-- This can be used as a default implementation for enumeration types:
171−--
172−-- >>> data MyData = Foo | Bar | Baz deriving (Show)
173−-- >>> instance ToHttpApiData MyData where toUrlPiece = showTextData
174−-- >>> toUrlPiece Foo
175−-- "foo"
176−showTextData :: Show a => a -> Text
177−showTextData = T.toLower . showt
178−
179−-- | Like @'show'@, but returns @'Text'@.
180−showt :: Show a => a -> Text
181−showt = T.pack . show
182−#endif
183−
184−-- | /Case insensitive/.
185−--
186−-- Parse given text case insensitive and then parse the rest of the input
187−-- using @'parseUrlPiece'@.
188−--
189−-- >>> parseUrlPieceWithPrefix "Just " "just 10" :: Either Text Int
190−-- Right 10
191−-- >>> parseUrlPieceWithPrefix "Left " "left" :: Either Text Bool
192−-- Left "could not parse: `left'"
193−--
194−-- This can be used to implement @'FromHttpApiData'@ for single field constructors:
195−--
196−-- >>> data Foo = Foo Int deriving (Show)
197−-- >>> instance FromHttpApiData Foo where parseUrlPiece s = Foo <$> parseUrlPieceWithPrefix "Foo " s
198−-- >>> parseUrlPiece "foo 1" :: Either Text Foo
199−-- Right (Foo 1)
200−parseUrlPieceWithPrefix :: FromHttpApiData a => Text -> Text -> Either Text a
201−parseUrlPieceWithPrefix pattern input
202− | T.toLower pattern == T.toLower prefix = parseUrlPiece rest
203− | otherwise = defaultParseError input
204− where
205− (prefix, rest) = T.splitAt (T.length pattern) input
206−
207−-- $setup
208−-- >>> data BasicAuthToken = BasicAuthToken Text deriving (Show)
209−-- >>> instance FromHttpApiData BasicAuthToken where parseHeader h = BasicAuthToken <$> parseHeaderWithPrefix "Basic " h; parseQueryParam p = BasicAuthToken <$> parseQueryParam p
210−
211−-- | Parse given bytestring then parse the rest of the input using @'parseHeader'@.
212−--
213−-- @
214−-- data BasicAuthToken = BasicAuthToken Text deriving (Show)
215−--
216−-- instance FromHttpApiData BasicAuthToken where
217−-- parseHeader h = BasicAuthToken \<$\> parseHeaderWithPrefix "Basic " h
218−-- parseQueryParam p = BasicAuthToken \<$\> parseQueryParam p
219−-- @
220−--
221−-- >>> parseHeader "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" :: Either Text BasicAuthToken
222−-- Right (BasicAuthToken "QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
223−parseHeaderWithPrefix :: FromHttpApiData a => ByteString -> ByteString -> Either Text a
224−parseHeaderWithPrefix pattern input
225− | pattern `BS.isPrefixOf` input = parseHeader (BS.drop (BS.length pattern) input)
226− | otherwise = defaultParseError (showt input)
227−
228−-- | /Case insensitive/.
229−--
230−-- Parse given text case insensitive and then parse the rest of the input
231−-- using @'parseQueryParam'@.
232−--
233−-- >>> parseQueryParamWithPrefix "z" "z10" :: Either Text Int
234−-- Right 10
235−parseQueryParamWithPrefix :: FromHttpApiData a => Text -> Text -> Either Text a
236−parseQueryParamWithPrefix pattern input
237− | T.toLower pattern == T.toLower prefix = parseQueryParam rest
238− | otherwise = defaultParseError input
239− where
240− (prefix, rest) = T.splitAt (T.length pattern) input
241−
242−#if USE_TEXT_SHOW
243−-- | /Case insensitive/.
244−--
245−-- Parse values case insensitively based on @'TextShow'@ instance.
246−--
247−-- >>> parseBoundedTextData "true" :: Either Text Bool
248−-- Right True
249−-- >>> parseBoundedTextData "FALSE" :: Either Text Bool
250−-- Right False
251−--
252−-- This can be used as a default implementation for enumeration types:
253−--
254−-- @
255−-- data MyData = Foo | Bar | Baz deriving (Show, Bounded, Enum, Generic)
256−--
257−-- instance TextShow MyData where
258−-- showt = genericShowt
259−--
260−-- instance FromHttpApiData MyData where
261−-- parseUrlPiece = parseBoundedTextData
262−-- @
263−parseBoundedTextData :: (TextShow a, Bounded a, Enum a) => Text -> Either Text a
264−#else
265−-- | /Case insensitive/.
266−--
267−-- Parse values case insensitively based on @'Show'@ instance.
268−--
269−-- >>> parseBoundedTextData "true" :: Either Text Bool
270−-- Right True
271−-- >>> parseBoundedTextData "FALSE" :: Either Text Bool
272−-- Right False
273−--
274−-- This can be used as a default implementation for enumeration types:
275−--
276−-- >>> data MyData = Foo | Bar | Baz deriving (Show, Bounded, Enum)
277−-- >>> instance FromHttpApiData MyData where parseUrlPiece = parseBoundedTextData
278−-- >>> parseUrlPiece "foo" :: Either Text MyData
279−-- Right Foo
280−parseBoundedTextData :: (Show a, Bounded a, Enum a) => Text -> Either Text a
281−#endif
282−parseBoundedTextData = parseMaybeTextData (flip lookup values . T.toLower)
283− where
284− values = map (showTextData &&& id) [minBound..maxBound]
285−
286−-- | Parse URL piece using @'Read'@ instance.
287−--
288−-- Use for types which do not involve letters:
289−--
290−-- >>> readTextData "1991-06-02" :: Either Text Day
291−-- Right 1991-06-02
292−--
293−-- This parser is case sensitive and will not match @'showTextData'@
294−-- in presense of letters:
295−--
296−-- >>> readTextData (showTextData True) :: Either Text Bool
297−-- Left "could not parse: `true'"
298−--
299−-- See @'parseBoundedTextData'@.
300−readTextData :: Read a => Text -> Either Text a
301−readTextData = parseMaybeTextData (readMaybe . T.unpack)
302−
303−-- | Run @'Reader'@ as HTTP API data parser.
304−runReader :: Reader a -> Text -> Either Text a
305−runReader reader input =
306− case reader input of
307− Left err -> Left ("could not parse: `" <> input <> "' (" <> T.pack err <> ")")
308− Right (x, rest)
309− | T.null rest -> Right x
310− | otherwise -> defaultParseError input
311−
312−-- | Run @'Reader'@ to parse bounded integral value with bounds checking.
313−--
314−-- >>> parseBounded decimal "256" :: Either Text Word8
315−-- Left "out of bounds: `256' (should be between 0 and 255)"
316−parseBounded :: forall a. (Bounded a, Integral a) => Reader Integer -> Text -> Either Text a
317−parseBounded reader input = do
318− n <- runReader reader input
319− if (n > h || n < l)
320− then Left ("out of bounds: `" <> input <> "' (should be between " <> showt l <> " and " <> showt h <> ")")
321− else Right (fromInteger n)
322− where
323− l = toInteger (minBound :: a)
324− h = toInteger (maxBound :: a)
325−
326−-- |
327−-- >>> toUrlPiece ()
328−-- "_"
329−instance ToHttpApiData () where
330− toUrlPiece () = "_"
331−
332−instance ToHttpApiData Char where toUrlPiece = T.singleton
333−
334−-- |
335−-- >>> toUrlPiece (Version [1, 2, 3] [])
336−-- "1.2.3"
337−instance ToHttpApiData Version where
338− toUrlPiece = T.pack . showVersion
339−
340−#if MIN_VERSION_base(4,8,0)
341−instance ToHttpApiData Void where
342− toUrlPiece = absurd
343−#endif
344−
345−instance ToHttpApiData Bool where toUrlPiece = showTextData
346−instance ToHttpApiData Ordering where toUrlPiece = showTextData
347−
348−instance ToHttpApiData Double where toUrlPiece = showt
349−instance ToHttpApiData Float where toUrlPiece = showt
350−instance ToHttpApiData Int where toUrlPiece = showt
351−instance ToHttpApiData Int8 where toUrlPiece = showt
352−instance ToHttpApiData Int16 where toUrlPiece = showt
353−instance ToHttpApiData Int32 where toUrlPiece = showt
354−instance ToHttpApiData Int64 where toUrlPiece = showt
355−instance ToHttpApiData Integer where toUrlPiece = showt
356−instance ToHttpApiData Word where toUrlPiece = showt
357−instance ToHttpApiData Word8 where toUrlPiece = showt
358−instance ToHttpApiData Word16 where toUrlPiece = showt
359−instance ToHttpApiData Word32 where toUrlPiece = showt
360−instance ToHttpApiData Word64 where toUrlPiece = showt
361−
362−-- |
363−-- >>> toUrlPiece (fromGregorian 2015 10 03)
364−-- "2015-10-03"
365−instance ToHttpApiData Day where toUrlPiece = T.pack . show
366−
367−instance ToHttpApiData String where toUrlPiece = T.pack
368−instance ToHttpApiData Text where toUrlPiece = id
369−instance ToHttpApiData L.Text where toUrlPiece = L.toStrict
370−
371−instance ToHttpApiData All where toUrlPiece = toUrlPiece . getAll
372−instance ToHttpApiData Any where toUrlPiece = toUrlPiece . getAny
373−
374−instance ToHttpApiData a => ToHttpApiData (Dual a) where toUrlPiece = toUrlPiece . getDual
375−instance ToHttpApiData a => ToHttpApiData (Sum a) where toUrlPiece = toUrlPiece . getSum
376−instance ToHttpApiData a => ToHttpApiData (Product a) where toUrlPiece = toUrlPiece . getProduct
377−instance ToHttpApiData a => ToHttpApiData (First a) where toUrlPiece = toUrlPiece . getFirst
378−instance ToHttpApiData a => ToHttpApiData (Last a) where toUrlPiece = toUrlPiece . getLast
379−
380−-- |
381−-- >>> toUrlPiece (Just "Hello")
382−-- "just Hello"
383−instance ToHttpApiData a => ToHttpApiData (Maybe a) where
384− toUrlPiece (Just x) = "just " <> toUrlPiece x
385− toUrlPiece Nothing = "nothing"
386−
387−-- |
388−-- >>> toUrlPiece (Left "err" :: Either String Int)
389−-- "left err"
390−-- >>> toUrlPiece (Right 3 :: Either String Int)
391−-- "right 3"
392−instance (ToHttpApiData a, ToHttpApiData b) => ToHttpApiData (Either a b) where
393− toUrlPiece (Left x) = "left " <> toUrlPiece x
394− toUrlPiece (Right x) = "right " <> toUrlPiece x
395−
396−-- |
397−-- >>> parseUrlPiece "_" :: Either Text ()
398−-- Right ()
399−instance FromHttpApiData () where
400− parseUrlPiece "_" = pure ()
401− parseUrlPiece s = defaultParseError s
402−
403−instance FromHttpApiData Char where
404− parseUrlPiece s =
405− case T.uncons s of
406− Just (c, s') | T.null s' -> pure c
407− _ -> defaultParseError s
408−
409−-- |
410−-- >>> showVersion <$> parseUrlPiece "1.2.3"
411−-- Right "1.2.3"
412−instance FromHttpApiData Version where
413− parseUrlPiece s =
414− case reverse (readP_to_S parseVersion (T.unpack s)) of
415− ((x, ""):_) -> pure x
416− _ -> defaultParseError s
417−
418−#if MIN_VERSION_base(4,8,0)
419−-- | Parsing a @'Void'@ value is always an error, considering @'Void'@ as a data type with no constructors.
420−instance FromHttpApiData Void where
421− parseUrlPiece _ = Left "Void cannot be parsed!"
422−#endif
423−
424−instance FromHttpApiData Bool where parseUrlPiece = parseBoundedTextData
425−instance FromHttpApiData Ordering where parseUrlPiece = parseBoundedTextData
426−instance FromHttpApiData Double where parseUrlPiece = runReader rational
427−instance FromHttpApiData Float where parseUrlPiece = runReader rational
428−instance FromHttpApiData Int where parseUrlPiece = parseBounded (signed decimal)
429−instance FromHttpApiData Int8 where parseUrlPiece = parseBounded (signed decimal)
430−instance FromHttpApiData Int16 where parseUrlPiece = parseBounded (signed decimal)
431−instance FromHttpApiData Int32 where parseUrlPiece = parseBounded (signed decimal)
432−instance FromHttpApiData Int64 where parseUrlPiece = parseBounded (signed decimal)
433−instance FromHttpApiData Integer where parseUrlPiece = runReader (signed decimal)
434−instance FromHttpApiData Word where parseUrlPiece = parseBounded decimal
435−instance FromHttpApiData Word8 where parseUrlPiece = parseBounded decimal
436−instance FromHttpApiData Word16 where parseUrlPiece = parseBounded decimal
437−instance FromHttpApiData Word32 where parseUrlPiece = parseBounded decimal
438−instance FromHttpApiData Word64 where parseUrlPiece = parseBounded decimal
439−instance FromHttpApiData String where parseUrlPiece = Right . T.unpack
440−instance FromHttpApiData Text where parseUrlPiece = Right
441−instance FromHttpApiData L.Text where parseUrlPiece = Right . L.fromStrict
442−
443−-- |
444−-- >>> toGregorian <$> parseUrlPiece "2016-12-01"
445−-- Right (2016,12,1)
446−instance FromHttpApiData Day where parseUrlPiece = readTextData
447−
448−instance FromHttpApiData All where parseUrlPiece = fmap All . parseUrlPiece
449−instance FromHttpApiData Any where parseUrlPiece = fmap Any . parseUrlPiece
450−
451−instance FromHttpApiData a => FromHttpApiData (Dual a) where parseUrlPiece = fmap Dual . parseUrlPiece
452−instance FromHttpApiData a => FromHttpApiData (Sum a) where parseUrlPiece = fmap Sum . parseUrlPiece
453−instance FromHttpApiData a => FromHttpApiData (Product a) where parseUrlPiece = fmap Product . parseUrlPiece
454−instance FromHttpApiData a => FromHttpApiData (First a) where parseUrlPiece = fmap First . parseUrlPiece
455−instance FromHttpApiData a => FromHttpApiData (Last a) where parseUrlPiece = fmap Last . parseUrlPiece
456−
457−-- |
458−-- >>> parseUrlPiece "Just 123" :: Either Text (Maybe Int)
459−-- Right (Just 123)
460−instance FromHttpApiData a => FromHttpApiData (Maybe a) where
461− parseUrlPiece s
462− | T.toLower (T.take 7 s) == "nothing" = pure Nothing
463− | otherwise = Just <$> parseUrlPieceWithPrefix "Just " s
464−
465−-- |
466−-- >>> parseUrlPiece "Right 123" :: Either Text (Either String Int)
467−-- Right (Right 123)
468−instance (FromHttpApiData a, FromHttpApiData b) => FromHttpApiData (Either a b) where
469− parseUrlPiece s =
470− Right <$> parseUrlPieceWithPrefix "Right " s
471− <!> Left <$> parseUrlPieceWithPrefix "Left " s
472− where
473− infixl 3 <!>
474− Left _ <!> y = y
475− x <!> _ = x
476−
cabal.haskell-cinew file+6
@@ -0,0 +1,6 @@
1+branches: master
2+doctest: <9
3+doctest-filter-packages: base-compat
4+
5+constraint-set text-show
6+ constraints: http-api-data +use-text-show
cabal.projectnew file+2
@@ -0,0 +1,2 @@
1+packages: .
2+tests: True
http-api-data.cabal+75−21
@@ -1,56 +1,110 @@
1+cabal-version: >= 1.10
1name: http-api-data
2name: http-api-data
2−version: 0.2
3+version: 0.5
4+
5+synopsis: Converting to/from HTTP API data like URL pieces, headers and query parameters.
6+category: Web
7+description:
8+ This package defines typeclasses used for converting Haskell data types to and from HTTP API data.
9+ .
10+ Please see README.md
11+
3license: BSD3
12license: BSD3
4license-file: LICENSE
13license-file: LICENSE
5author: Nickolay Kudasov <nickolay.kudasov@gmail.com>
14author: Nickolay Kudasov <nickolay.kudasov@gmail.com>
6maintainer: Nickolay Kudasov <nickolay.kudasov@gmail.com>
15maintainer: Nickolay Kudasov <nickolay.kudasov@gmail.com>
7−synopsis: Converting to/from HTTP API data like URL pieces, headers and query parameters.
8−description: Please see README.md
9homepage: http://github.com/fizruk/http-api-data
16homepage: http://github.com/fizruk/http-api-data
10−category: Web
11stability: unstable
17stability: unstable
12−cabal-version: >= 1.8
13build-type: Simple
18build-type: Simple
19+
14extra-source-files:
20extra-source-files:
21+ include/overlapping-compat.h
15 test/*.hs
22 test/*.hs
16 CHANGELOG.md
23 CHANGELOG.md
17 README.md
24 README.md
18
25
26+tested-with:
27+ GHC==8.0.2,
28+ GHC==8.2.2,
29+ GHC==8.4.4,
30+ GHC==8.6.5,
31+ GHC==8.8.4,
32+ GHC==8.10.7,
33+ GHC==9.0.2,
34+ GHC==9.2.3,
35+ GHC==9.4.1
36+
19flag use-text-show
37flag use-text-show
20 description: Use text-show library for efficient ToHttpApiData implementations.
38 description: Use text-show library for efficient ToHttpApiData implementations.
21 default: False
39 default: False
40+ manual: True
22
41
23library
42library
43+ hs-source-dirs: src/
44+ include-dirs: include/
45+
46+ -- GHC bundled
24− build-depends: base >= 4 && < 5
47+ build-depends: base >= 4.9 && < 4.17
25− , text >= 0.5
26− , bytestring
48+ , bytestring >= 0.10.8.1 && < 0.12
49+ , containers >= 0.5.7.1 && < 0.7
50+ , text >= 1.2.3.0 && < 1.3 || >=2.0 && <2.1
51+ , transformers >= 0.5.2.0 && < 0.7
52+
53+ -- other-dependencies
54+ build-depends:
55+ attoparsec >= 0.13.2.2 && < 0.15
56+ , attoparsec-iso8601 >= 1.1.0.0 && < 1.2
57+ , base-compat >= 0.10.5 && < 0.13
58+ , cookie >= 0.4.3 && < 0.5
59+ , hashable >= 1.2.7.0 && < 1.5
60+ , http-types >= 0.12.3 && < 0.13
61+ , tagged >= 0.8.5 && < 0.9
27− , time
62+ , time-compat >= 1.9.5 && < 1.10
63+ , unordered-containers >= 0.2.10.0 && < 0.3
64+ , uuid-types >= 1.0.3 && < 1.1
65+
28 if flag(use-text-show)
66 if flag(use-text-show)
29 cpp-options: -DUSE_TEXT_SHOW
67 cpp-options: -DUSE_TEXT_SHOW
30− build-depends: text-show >= 2
68+ build-depends: text-show >= 3.8.2 && <3.10
69+
31 exposed-modules:
70 exposed-modules:
32 Web.HttpApiData
71 Web.HttpApiData
72+ Web.FormUrlEncoded
73+ Web.Internal.FormUrlEncoded
33− Web.HttpApiData.Internal
74+ Web.Internal.HttpApiData
34 ghc-options: -Wall
75 ghc-options: -Wall
76+ default-language: Haskell2010
35
77
36test-suite spec
78test-suite spec
37 type: exitcode-stdio-1.0
79 type: exitcode-stdio-1.0
38 main-is: Spec.hs
80 main-is: Spec.hs
81+ other-modules:
82+ Web.Internal.FormUrlEncodedSpec
83+ Web.Internal.HttpApiDataSpec
84+ Web.Internal.TestInstances
39 hs-source-dirs: test
85 hs-source-dirs: test
40 ghc-options: -Wall
86 ghc-options: -Wall
41− build-depends: HUnit
87+ default-language: Haskell2010
42− , hspec >= 1.3
88+ build-tool-depends: hspec-discover:hspec-discover >= 2.7.1 && <2.11
43− , base >= 4 && < 5
89+ -- inherited depndencies
44− , QuickCheck
90+ build-depends:
91+ base
92+ , base-compat
93+ , bytestring
94+ , cookie
45 , http-api-data
95 , http-api-data
46 , text
96 , text
47− , time
97+ , time-compat
98+ , unordered-containers
99+ , uuid-types
100+
101+ if !impl(ghc >= 7.10)
102+ build-depends: nats
48
103
49−test-suite doctest
50− build-depends: base, doctest, Glob
104+ build-depends: HUnit >= 1.6.0.0 && <1.7
51− hs-source-dirs: test
105+ , hspec >= 2.7.1 && <2.11
52− main-is: DocTest.hs
106+ , QuickCheck >= 2.13.1 && <2.15
53− type: exitcode-stdio-1.0
107+ , quickcheck-instances >= 0.3.25.2 && <0.4
54
108
55source-repository head
109source-repository head
56 type: git
110 type: git
include/overlapping-compat.hnew file+8
@@ -0,0 +1,8 @@
1+#if __GLASGOW_HASKELL__ >= 710
2+#define OVERLAPPABLE_ {-# OVERLAPPABLE #-}
3+#define OVERLAPPING_ {-# OVERLAPPING #-}
4+#else
5+{-# LANGUAGE OverlappingInstances #-}
6+#define OVERLAPPABLE_
7+#define OVERLAPPING_
8+#endif
src/Web/FormUrlEncoded.hsnew file+51
@@ -0,0 +1,51 @@
1+-- |
2+-- Convert Haskell values to and from @application/xxx-form-urlencoded@ format.
3+module Web.FormUrlEncoded (
4+ -- * Classes
5+ ToForm (..),
6+ FromForm (..),
7+
8+ -- ** Keys for 'Form' entries
9+ ToFormKey(..),
10+ FromFormKey(..),
11+
12+ -- * 'Form' type
13+ Form(..),
14+
15+ -- * Encoding and decoding @'Form'@s
16+ urlEncodeAsForm,
17+ urlEncodeAsFormStable,
18+ urlDecodeAsForm,
19+
20+ urlEncodeForm,
21+ urlEncodeFormStable,
22+ urlDecodeForm,
23+
24+ -- * 'Generic's
25+ genericToForm,
26+ genericFromForm,
27+
28+ -- ** Encoding options
29+ FormOptions(..),
30+ defaultFormOptions,
31+
32+ -- * Helpers
33+ toListStable,
34+ toEntriesByKey,
35+ toEntriesByKeyStable,
36+ fromEntriesByKey,
37+
38+ lookupAll,
39+ lookupMaybe,
40+ lookupUnique,
41+
42+ parseAll,
43+ parseMaybe,
44+ parseUnique,
45+
46+ urlEncodeParams,
47+ urlDecodeParams,
48+) where
49+
50+import Web.Internal.FormUrlEncoded
51+
Web/HttpApiData.hs → src/Web/HttpApiData.hsrenamed+14−5
@@ -27,22 +27,31 @@ module Web.HttpApiData (
27 toQueryParams,
27 toQueryParams,
28 parseQueryParams,
28 parseQueryParams,
29
29
30+ -- * Parsers for @'Bounded'@ @'Enum'@s
31+ parseBoundedUrlPiece,
32+ parseBoundedQueryParam,
33+ parseBoundedHeader,
34+ parseBoundedEnumOf,
35+ parseBoundedEnumOfI,
36+ parseBoundedTextData,
37+
38+ -- * Lenient data
39+ LenientData (..),
40+
30 -- * Other helpers
41 -- * Other helpers
31 showTextData,
42 showTextData,
32 readTextData,
43 readTextData,
33− parseBoundedTextData,
34) where
44) where
35
45
36−import Web.HttpApiData.Internal
46+import Web.Internal.HttpApiData
37
47
38-- $setup
48-- $setup
39--
49--
40-- >>> :set -XOverloadedStrings
50-- >>> :set -XOverloadedStrings
41-- >>> import Control.Applicative
51-- >>> import Control.Applicative
42−-- >>> import Data.Time
52+-- >>> import Data.Time.Compat
43-- >>> import Data.Int
53-- >>> import Data.Int
44-- >>> import Data.Text (Text)
54-- >>> import Data.Text (Text)
45−-- >>> import Data.Time (Day)
46-- >>> import Data.Version
55-- >>> import Data.Version
47
56
48-- $examples
57-- $examples
@@ -62,7 +71,7 @@ import Web.HttpApiData.Internal
62-- "45.2"
71-- "45.2"
63-- >>> parseQueryParam "452" :: Either Text Int
72-- >>> parseQueryParam "452" :: Either Text Int
64-- Right 452
73-- Right 452
65−-- >>> toQueryParams [1..5]
74+-- >>> toQueryParams [1..5] :: [Text]
66-- ["1","2","3","4","5"]
75-- ["1","2","3","4","5"]
67-- >>> parseQueryParams ["127", "255"] :: Either Text [Int8]
76-- >>> parseQueryParams ["127", "255"] :: Either Text [Int8]
68-- Left "out of bounds: `255' (should be between -128 and 127)"
77-- Left "out of bounds: `255' (should be between -128 and 127)"
src/Web/Internal/FormUrlEncoded.hsnew file+796
@@ -0,0 +1,796 @@
1+{-# LANGUAGE ConstraintKinds #-}
2+{-# LANGUAGE CPP #-}
3+{-# LANGUAGE DataKinds #-}
4+{-# LANGUAGE DefaultSignatures #-}
5+{-# LANGUAGE DeriveGeneric #-}
6+{-# LANGUAGE FlexibleContexts #-}
7+{-# LANGUAGE FlexibleInstances #-}
8+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
9+{-# LANGUAGE KindSignatures #-}
10+{-# LANGUAGE MultiParamTypeClasses #-}
11+{-# LANGUAGE OverloadedStrings #-}
12+{-# LANGUAGE PolyKinds #-}
13+{-# LANGUAGE ScopedTypeVariables #-}
14+{-# LANGUAGE TypeFamilies #-}
15+{-# LANGUAGE TypeOperators #-}
16+{-# LANGUAGE UndecidableInstances #-}
17+#include "overlapping-compat.h"
18+module Web.Internal.FormUrlEncoded where
19+
20+import Prelude ()
21+import Prelude.Compat
22+
23+import Control.Applicative (Const(Const))
24+import Control.Arrow ((***))
25+import Control.Monad ((<=<))
26+import Data.ByteString.Builder (shortByteString, toLazyByteString)
27+import qualified Data.ByteString.Lazy as BSL
28+import qualified Data.ByteString.Lazy.Char8 as BSL8
29+import Data.Coerce (coerce)
30+import qualified Data.Foldable as F
31+import Data.Functor.Identity (Identity(Identity))
32+import Data.Hashable (Hashable)
33+import Data.HashMap.Strict (HashMap)
34+import qualified Data.HashMap.Strict as HashMap
35+import Data.Int (Int16, Int32, Int64, Int8)
36+import Data.IntMap (IntMap)
37+import qualified Data.IntMap as IntMap
38+import Data.List (intersperse, sortBy)
39+import Data.Map (Map)
40+import qualified Data.Map as Map
41+import Data.Monoid (All (..), Any (..), Dual (..),
42+ Product (..), Sum (..))
43+import Data.Ord (comparing)
44+import Data.Proxy (Proxy (..))
45+import Data.Semigroup (Semigroup (..))
46+import qualified Data.Semigroup as Semi
47+import Data.Tagged (Tagged (..))
48+import Data.Text (Text)
49+import qualified Data.Text as Text
50+import qualified Data.Text.Encoding as Text
51+import Data.Text.Encoding.Error (lenientDecode)
52+import qualified Data.Text.Lazy as Lazy
53+import Data.Time.Compat (Day, LocalTime, NominalDiffTime,
54+ UTCTime, ZonedTime)
55+import Data.Time.Calendar.Month.Compat (Month)
56+import Data.Time.Calendar.Quarter.Compat (Quarter, QuarterOfYear (..))
57+import Data.Void (Void)
58+import Data.Word (Word16, Word32, Word64, Word8)
59+import GHC.Exts (Constraint, IsList (..))
60+import GHC.Generics
61+import GHC.TypeLits
62+import Network.HTTP.Types.URI (urlDecode, urlEncodeBuilder)
63+import Numeric.Natural (Natural)
64+import Web.Internal.HttpApiData
65+
66+-- $setup
67+-- >>> :set -XDeriveGeneric
68+-- >>> :set -XOverloadedLists
69+-- >>> :set -XOverloadedStrings
70+-- >>> :set -XFlexibleContexts
71+-- >>> :set -XScopedTypeVariables
72+-- >>> :set -XTypeFamilies
73+-- >>> import Data.Char (toLower)
74+--
75+-- >>> data Person = Person { name :: String, age :: Int } deriving (Show, Generic)
76+-- >>> instance ToForm Person
77+-- >>> instance FromForm Person
78+--
79+-- >>> data Post = Post { title :: String, subtitle :: Maybe String, comments :: [String]} deriving (Generic, Show)
80+-- >>> instance ToForm Post
81+-- >>> instance FromForm Post
82+--
83+-- >>> data Project = Project { projectName :: String, projectSize :: Int } deriving (Generic, Show)
84+-- >>> let myOptions = FormOptions { fieldLabelModifier = map toLower . drop (length ("project" :: String)) }
85+-- >>> instance ToForm Project where toForm = genericToForm myOptions
86+-- >>> instance FromForm Project where fromForm = genericFromForm myOptions
87+
88+-- | Typeclass for types that can be used as keys in a 'Form'-like container (like 'Map').
89+class ToFormKey k where
90+ -- | Render a key for a 'Form'.
91+ toFormKey :: k -> Text
92+
93+instance ToFormKey () where toFormKey = toQueryParam
94+instance ToFormKey Char where toFormKey = toQueryParam
95+
96+instance ToFormKey Bool where toFormKey = toQueryParam
97+instance ToFormKey Ordering where toFormKey = toQueryParam
98+
99+instance ToFormKey Double where toFormKey = toQueryParam
100+instance ToFormKey Float where toFormKey = toQueryParam
101+instance ToFormKey Int where toFormKey = toQueryParam
102+instance ToFormKey Int8 where toFormKey = toQueryParam
103+instance ToFormKey Int16 where toFormKey = toQueryParam
104+instance ToFormKey Int32 where toFormKey = toQueryParam
105+instance ToFormKey Int64 where toFormKey = toQueryParam
106+instance ToFormKey Integer where toFormKey = toQueryParam
107+instance ToFormKey Word where toFormKey = toQueryParam
108+instance ToFormKey Word8 where toFormKey = toQueryParam
109+instance ToFormKey Word16 where toFormKey = toQueryParam
110+instance ToFormKey Word32 where toFormKey = toQueryParam
111+instance ToFormKey Word64 where toFormKey = toQueryParam
112+
113+instance ToFormKey Day where toFormKey = toQueryParam
114+instance ToFormKey LocalTime where toFormKey = toQueryParam
115+instance ToFormKey ZonedTime where toFormKey = toQueryParam
116+instance ToFormKey UTCTime where toFormKey = toQueryParam
117+instance ToFormKey NominalDiffTime where toFormKey = toQueryParam
118+instance ToFormKey Quarter where toFormKey = toQueryParam
119+instance ToFormKey QuarterOfYear where toFormKey = toQueryParam
120+instance ToFormKey Month where toFormKey = toQueryParam
121+
122+instance ToFormKey String where toFormKey = toQueryParam
123+instance ToFormKey Text where toFormKey = toQueryParam
124+instance ToFormKey Lazy.Text where toFormKey = toQueryParam
125+
126+instance ToFormKey All where toFormKey = toQueryParam
127+instance ToFormKey Any where toFormKey = toQueryParam
128+
129+instance ToFormKey a => ToFormKey (Dual a) where toFormKey = coerce (toFormKey :: a -> Text)
130+instance ToFormKey a => ToFormKey (Sum a) where toFormKey = coerce (toFormKey :: a -> Text)
131+instance ToFormKey a => ToFormKey (Product a) where toFormKey = coerce (toFormKey :: a -> Text)
132+
133+instance ToFormKey a => ToFormKey (Semi.Min a) where toFormKey = coerce (toFormKey :: a -> Text)
134+instance ToFormKey a => ToFormKey (Semi.Max a) where toFormKey = coerce (toFormKey :: a -> Text)
135+instance ToFormKey a => ToFormKey (Semi.First a) where toFormKey = coerce (toFormKey :: a -> Text)
136+instance ToFormKey a => ToFormKey (Semi.Last a) where toFormKey = coerce (toFormKey :: a -> Text)
137+
138+instance ToFormKey a => ToFormKey (Tagged b a) where toFormKey = coerce (toFormKey :: a -> Text)
139+
140+-- | @since 0.4.2
141+instance ToFormKey a => ToFormKey (Identity a) where toFormKey = coerce (toFormKey :: a -> Text)
142+
143+-- | @since 0.4.2
144+instance ToFormKey a => ToFormKey (Const a b) where
145+ toFormKey = coerce (toFormKey :: a -> Text)
146+
147+instance ToFormKey Void where toFormKey = toQueryParam
148+instance ToFormKey Natural where toFormKey = toQueryParam
149+
150+-- | Typeclass for types that can be parsed from keys of a 'Form'. This is the reverse of 'ToFormKey'.
151+class FromFormKey k where
152+ -- | Parse a key of a 'Form'.
153+ parseFormKey :: Text -> Either Text k
154+
155+instance FromFormKey () where parseFormKey = parseQueryParam
156+instance FromFormKey Char where parseFormKey = parseQueryParam
157+
158+instance FromFormKey Bool where parseFormKey = parseQueryParam
159+instance FromFormKey Ordering where parseFormKey = parseQueryParam
160+
161+instance FromFormKey Double where parseFormKey = parseQueryParam
162+instance FromFormKey Float where parseFormKey = parseQueryParam
163+instance FromFormKey Int where parseFormKey = parseQueryParam
164+instance FromFormKey Int8 where parseFormKey = parseQueryParam
165+instance FromFormKey Int16 where parseFormKey = parseQueryParam
166+instance FromFormKey Int32 where parseFormKey = parseQueryParam
167+instance FromFormKey Int64 where parseFormKey = parseQueryParam
168+instance FromFormKey Integer where parseFormKey = parseQueryParam
169+instance FromFormKey Word where parseFormKey = parseQueryParam
170+instance FromFormKey Word8 where parseFormKey = parseQueryParam
171+instance FromFormKey Word16 where parseFormKey = parseQueryParam
172+instance FromFormKey Word32 where parseFormKey = parseQueryParam
173+instance FromFormKey Word64 where parseFormKey = parseQueryParam
174+
175+instance FromFormKey Day where parseFormKey = parseQueryParam
176+instance FromFormKey LocalTime where parseFormKey = parseQueryParam
177+instance FromFormKey ZonedTime where parseFormKey = parseQueryParam
178+instance FromFormKey UTCTime where parseFormKey = parseQueryParam
179+instance FromFormKey NominalDiffTime where parseFormKey = parseQueryParam
180+instance FromFormKey Quarter where parseFormKey = parseQueryParam
181+instance FromFormKey QuarterOfYear where parseFormKey = parseQueryParam
182+instance FromFormKey Month where parseFormKey = parseQueryParam
183+
184+instance FromFormKey String where parseFormKey = parseQueryParam
185+instance FromFormKey Text where parseFormKey = parseQueryParam
186+instance FromFormKey Lazy.Text where parseFormKey = parseQueryParam
187+
188+instance FromFormKey All where parseFormKey = parseQueryParam
189+instance FromFormKey Any where parseFormKey = parseQueryParam
190+
191+instance FromFormKey a => FromFormKey (Dual a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
192+instance FromFormKey a => FromFormKey (Sum a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
193+instance FromFormKey a => FromFormKey (Product a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
194+
195+instance FromFormKey a => FromFormKey (Semi.Min a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
196+instance FromFormKey a => FromFormKey (Semi.Max a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
197+instance FromFormKey a => FromFormKey (Semi.First a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
198+instance FromFormKey a => FromFormKey (Semi.Last a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
199+
200+instance FromFormKey a => FromFormKey (Tagged b a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
201+
202+-- | @since 0.4.2
203+instance FromFormKey a => FromFormKey (Identity a) where parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
204+
205+-- | @since 0.4.2
206+instance FromFormKey a => FromFormKey (Const a b) where
207+ parseFormKey = coerce (parseFormKey :: Text -> Either Text a)
208+
209+instance FromFormKey Void where parseFormKey = parseQueryParam
210+instance FromFormKey Natural where parseFormKey = parseQueryParam
211+
212+-- | The contents of a form, not yet URL-encoded.
213+--
214+-- 'Form' can be URL-encoded with 'urlEncodeForm' and URL-decoded with 'urlDecodeForm'.
215+newtype Form = Form { unForm :: HashMap Text [Text] }
216+ deriving (Eq, Read, Generic, Semigroup, Monoid)
217+
218+instance Show Form where
219+ showsPrec d form = showParen (d > 10) $
220+ showString "fromList " . shows (toListStable form)
221+
222+-- | _NOTE:_ 'toList' is unstable and may result in different key order (but not values).
223+-- For a stable conversion use 'toListStable'.
224+instance IsList Form where
225+ type Item Form = (Text, Text)
226+ fromList = Form . HashMap.fromListWith (flip (<>)) . fmap (\(k, v) -> (k, [v]))
227+ toList = concatMap (\(k, vs) -> map ((,) k) vs) . HashMap.toList . unForm
228+
229+-- | A stable version of 'toList'.
230+toListStable :: Form -> [(Text, Text)]
231+toListStable = sortOn fst . toList
232+
233+-- | Convert a value into 'Form'.
234+--
235+-- An example type and instance:
236+--
237+-- @
238+-- {-\# LANGUAGE OverloadedLists \#-}
239+--
240+-- data Person = Person
241+-- { name :: String
242+-- , age :: Int }
243+--
244+-- instance 'ToForm' Person where
245+-- 'toForm' person =
246+-- [ (\"name\", 'toQueryParam' (name person))
247+-- , (\"age\", 'toQueryParam' (age person)) ]
248+-- @
249+--
250+-- Instead of manually writing @'ToForm'@ instances you can
251+-- use a default generic implementation of @'toForm'@.
252+--
253+-- To do that, simply add @deriving 'Generic'@ clause to your datatype
254+-- and declare a 'ToForm' instance for your datatype without
255+-- giving definition for 'toForm'.
256+--
257+-- For instance, the previous example can be simplified into this:
258+--
259+-- @
260+-- data Person = Person
261+-- { name :: String
262+-- , age :: Int
263+-- } deriving ('Generic')
264+--
265+-- instance 'ToForm' Person
266+-- @
267+--
268+-- The default implementation of 'toForm' is 'genericToForm'.
269+class ToForm a where
270+ -- | Convert a value into 'Form'.
271+ toForm :: a -> Form
272+ default toForm :: (Generic a, GToForm a (Rep a)) => a -> Form
273+ toForm = genericToForm defaultFormOptions
274+
275+instance ToForm Form where toForm = id
276+
277+instance (ToFormKey k, ToHttpApiData v) => ToForm [(k, v)] where
278+ toForm = fromList . map (toFormKey *** toQueryParam)
279+
280+instance (ToFormKey k, ToHttpApiData v) => ToForm (Map k [v]) where
281+ toForm = fromEntriesByKey . Map.toList
282+
283+instance (ToFormKey k, ToHttpApiData v) => ToForm (HashMap k [v]) where
284+ toForm = fromEntriesByKey . HashMap.toList
285+
286+instance ToHttpApiData v => ToForm (IntMap [v]) where
287+ toForm = fromEntriesByKey . IntMap.toList
288+
289+-- | Convert a list of entries groupped by key into a 'Form'.
290+--
291+-- >>> fromEntriesByKey [("name",["Nick"]),("color",["red","blue"])]
292+-- fromList [("color","red"),("color","blue"),("name","Nick")]
293+fromEntriesByKey :: (ToFormKey k, ToHttpApiData v) => [(k, [v])] -> Form
294+fromEntriesByKey = Form . HashMap.fromListWith (<>) . map (toFormKey *** map toQueryParam)
295+
296+data Proxy3 a b c = Proxy3
297+
298+type family NotSupported (cls :: k1) (a :: k2) (reason :: Symbol) :: Constraint where
299+#if __GLASGOW_HASKELL__ < 800
300+ -- this is just a placeholder case for older GHCs to not freak out on an empty closed type family
301+ NotSupported cls a "this type family is actually empty" = ()
302+#else
303+ NotSupported cls a reason = TypeError
304+ ( 'Text "Cannot derive a Generic-based " ':<>: 'ShowType cls ':<>: 'Text " instance for " ':<>: 'ShowType a ':<>: 'Text "." ':$$:
305+ 'ShowType a ':<>: 'Text " " ':<>: 'Text reason ':<>: 'Text "," ':$$:
306+ 'Text "but Generic-based " ':<>: 'ShowType cls ':<>: 'Text " instances can be derived only for records" ':$$:
307+ 'Text "(i.e. product types with named fields)." )
308+#endif
309+
310+-- | A 'Generic'-based implementation of 'toForm'.
311+-- This is used as a default implementation in 'ToForm'.
312+--
313+-- Note that this only works for records (i.e. product data types with named fields):
314+--
315+-- @
316+-- data Person = Person
317+-- { name :: String
318+-- , age :: Int
319+-- } deriving ('Generic')
320+-- @
321+--
322+-- In this implementation each field's value gets encoded using `toQueryParam`.
323+-- Two field types are exceptions:
324+--
325+-- - for values of type @'Maybe' a@ an entry is added to the 'Form' only when it is @'Just' x@
326+-- and the encoded value is @'toQueryParam' x@; 'Nothing' values are omitted from the 'Form';
327+--
328+-- - for values of type @[a]@ (except @['Char']@) an entry is added for every item in the list;
329+-- if the list is empty no entries are added to the 'Form';
330+--
331+-- Here's an example:
332+--
333+-- @
334+-- data Post = Post
335+-- { title :: String
336+-- , subtitle :: Maybe String
337+-- , comments :: [String]
338+-- } deriving ('Generic', 'Show')
339+--
340+-- instance 'ToForm' Post
341+-- @
342+--
343+-- >>> urlEncodeAsFormStable Post { title = "Test", subtitle = Nothing, comments = ["Nice post!", "+1"] }
344+-- "comments=Nice%20post%21&comments=%2B1&title=Test"
345+genericToForm :: forall a. (Generic a, GToForm a (Rep a)) => FormOptions -> a -> Form
346+genericToForm opts = gToForm (Proxy :: Proxy a) opts . from
347+
348+class GToForm t (f :: * -> *) where
349+ gToForm :: Proxy t -> FormOptions -> f x -> Form
350+
351+instance (GToForm t f, GToForm t g) => GToForm t (f :*: g) where
352+ gToForm p opts (a :*: b) = gToForm p opts a <> gToForm p opts b
353+
354+instance (GToForm t f) => GToForm t (M1 D x f) where
355+ gToForm p opts (M1 a) = gToForm p opts a
356+
357+instance (GToForm t f) => GToForm t (M1 C x f) where
358+ gToForm p opts (M1 a) = gToForm p opts a
359+
360+instance OVERLAPPABLE_ (Selector s, ToHttpApiData c) => GToForm t (M1 S s (K1 i c)) where
361+ gToForm _ opts (M1 (K1 c)) = fromList [(key, toQueryParam c)]
362+ where
363+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
364+
365+instance (Selector s, ToHttpApiData c) => GToForm t (M1 S s (K1 i (Maybe c))) where
366+ gToForm _ opts (M1 (K1 c)) =
367+ case c of
368+ Nothing -> mempty
369+ Just x -> fromList [(key, toQueryParam x)]
370+ where
371+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
372+
373+instance (Selector s, ToHttpApiData c) => GToForm t (M1 S s (K1 i [c])) where
374+ gToForm _ opts (M1 (K1 cs)) = fromList (map (\c -> (key, toQueryParam c)) cs)
375+ where
376+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
377+
378+instance OVERLAPPING_ (Selector s) => GToForm t (M1 S s (K1 i String)) where
379+ gToForm _ opts (M1 (K1 c)) = fromList [(key, toQueryParam c)]
380+ where
381+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
382+
383+instance NotSupported ToForm t "is a sum type" => GToForm t (f :+: g) where gToForm = error "impossible"
384+
385+-- | Parse 'Form' into a value.
386+--
387+-- An example type and instance:
388+--
389+-- @
390+-- data Person = Person
391+-- { name :: String
392+-- , age :: Int }
393+--
394+-- instance 'FromForm' Person where
395+-- 'fromForm' f = Person
396+-- '<$>' 'parseUnique' "name" f
397+-- '<*>' 'parseUnique' "age" f
398+-- @
399+--
400+-- Instead of manually writing @'FromForm'@ instances you can
401+-- use a default generic implementation of @'fromForm'@.
402+--
403+-- To do that, simply add @deriving 'Generic'@ clause to your datatype
404+-- and declare a 'FromForm' instance for your datatype without
405+-- giving definition for 'fromForm'.
406+--
407+-- For instance, the previous example can be simplified into this:
408+--
409+-- @
410+-- data Person = Person
411+-- { name :: String
412+-- , age :: Int
413+-- } deriving ('Generic')
414+--
415+-- instance 'FromForm' Person
416+-- @
417+--
418+-- The default implementation of 'fromForm' is 'genericFromForm'.
419+-- It only works for records and it will use 'parseQueryParam' for each field's value.
420+class FromForm a where
421+ -- | Parse 'Form' into a value.
422+ fromForm :: Form -> Either Text a
423+ default fromForm :: (Generic a, GFromForm a (Rep a)) => Form -> Either Text a
424+ fromForm = genericFromForm defaultFormOptions
425+
426+instance FromForm Form where fromForm = pure
427+
428+-- | _NOTE:_ this conversion is unstable and may result in different key order (but not values).
429+instance (FromFormKey k, FromHttpApiData v) => FromForm [(k, v)] where
430+ fromForm = fmap (concatMap (\(k, vs) -> map ((,) k) vs)) . toEntriesByKey
431+
432+instance (Ord k, FromFormKey k, FromHttpApiData v) => FromForm (Map k [v]) where
433+ fromForm = fmap (Map.fromListWith (<>)) . toEntriesByKey
434+
435+instance (Eq k, Hashable k, FromFormKey k, FromHttpApiData v) => FromForm (HashMap k [v]) where
436+ fromForm = fmap (HashMap.fromListWith (<>)) . toEntriesByKey
437+
438+instance FromHttpApiData v => FromForm (IntMap [v]) where
439+ fromForm = fmap (IntMap.fromListWith (<>)) . toEntriesByKey
440+
441+-- | Parse a 'Form' into a list of entries groupped by key.
442+--
443+-- _NOTE:_ this conversion is unstable and may result in different key order
444+-- (but not values). For a stable encoding see 'toEntriesByKeyStable'.
445+toEntriesByKey :: (FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
446+toEntriesByKey = traverse parseGroup . HashMap.toList . unForm
447+ where
448+ parseGroup (k, vs) = (,) <$> parseFormKey k <*> traverse parseQueryParam vs
449+
450+-- | Parse a 'Form' into a list of entries groupped by key.
451+--
452+-- >>> toEntriesByKeyStable [("name", "Nick"), ("color", "red"), ("color", "white")] :: Either Text [(Text, [Text])]
453+-- Right [("color",["red","white"]),("name",["Nick"])]
454+--
455+-- For an unstable (but faster) conversion see 'toEntriesByKey'.
456+toEntriesByKeyStable :: (Ord k, FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
457+toEntriesByKeyStable = fmap (sortOn fst) . toEntriesByKey
458+
459+-- | A 'Generic'-based implementation of 'fromForm'.
460+-- This is used as a default implementation in 'FromForm'.
461+--
462+-- Note that this only works for records (i.e. product data types with named fields):
463+--
464+-- @
465+-- data Person = Person
466+-- { name :: String
467+-- , age :: Int
468+-- } deriving ('Generic')
469+-- @
470+--
471+-- In this implementation each field's value gets decoded using `parseQueryParam`.
472+-- Two field types are exceptions:
473+--
474+-- - for values of type @'Maybe' a@ an entry is parsed if present in the 'Form'
475+-- and the is decoded with 'parseQueryParam'; if no entry is present result is 'Nothing';
476+--
477+-- - for values of type @[a]@ (except @['Char']@) all entries are parsed to produce a list of parsed values;
478+--
479+-- Here's an example:
480+--
481+-- @
482+-- data Post = Post
483+-- { title :: String
484+-- , subtitle :: Maybe String
485+-- , comments :: [String]
486+-- } deriving ('Generic', 'Show')
487+--
488+-- instance 'FromForm' Post
489+-- @
490+--
491+-- >>> urlDecodeAsForm "comments=Nice%20post%21&comments=%2B1&title=Test" :: Either Text Post
492+-- Right (Post {title = "Test", subtitle = Nothing, comments = ["Nice post!","+1"]})
493+genericFromForm :: forall a. (Generic a, GFromForm a (Rep a)) => FormOptions -> Form -> Either Text a
494+genericFromForm opts f = to <$> gFromForm (Proxy :: Proxy a) opts f
495+
496+class GFromForm t (f :: * -> *) where
497+ gFromForm :: Proxy t -> FormOptions -> Form -> Either Text (f x)
498+
499+instance (GFromForm t f, GFromForm t g) => GFromForm t (f :*: g) where
500+ gFromForm p opts f = (:*:) <$> gFromForm p opts f <*> gFromForm p opts f
501+
502+instance GFromForm t f => GFromForm t (M1 D x f) where
503+ gFromForm p opts f = M1 <$> gFromForm p opts f
504+
505+instance GFromForm t f => GFromForm t (M1 C x f) where
506+ gFromForm p opts f = M1 <$> gFromForm p opts f
507+
508+instance OVERLAPPABLE_ (Selector s, FromHttpApiData c) => GFromForm t (M1 S s (K1 i c)) where
509+ gFromForm _ opts form = M1 . K1 <$> parseUnique key form
510+ where
511+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
512+
513+instance (Selector s, FromHttpApiData c) => GFromForm t (M1 S s (K1 i (Maybe c))) where
514+ gFromForm _ opts form = M1 . K1 <$> parseMaybe key form
515+ where
516+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
517+
518+instance (Selector s, FromHttpApiData c) => GFromForm t (M1 S s (K1 i [c])) where
519+ gFromForm _ opts form = M1 . K1 <$> parseAll key form
520+ where
521+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
522+
523+instance OVERLAPPING_ (Selector s) => GFromForm t (M1 S s (K1 i String)) where
524+ gFromForm _ opts form = M1 . K1 <$> parseUnique key form
525+ where
526+ key = Text.pack $ fieldLabelModifier opts $ selName (Proxy3 :: Proxy3 s g p)
527+
528+instance NotSupported FromForm t "is a sum type" => GFromForm t (f :+: g) where gFromForm = error "impossible"
529+
530+-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
531+--
532+-- _NOTE:_ this encoding is unstable and may result in different key order
533+-- (but not values). For a stable encoding see 'urlEncodeFormStable'.
534+urlEncodeForm :: Form -> BSL.ByteString
535+urlEncodeForm = urlEncodeParams . toList
536+
537+-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
538+--
539+-- For an unstable (but faster) encoding see 'urlEncodeForm'.
540+--
541+-- Key-value pairs get encoded to @key=value@ and separated by @&@:
542+--
543+-- >>> urlEncodeFormStable [("name", "Julian"), ("lastname", "Arni")]
544+-- "lastname=Arni&name=Julian"
545+--
546+-- Keys with empty values get encoded to just @key@ (without the @=@ sign):
547+--
548+-- >>> urlEncodeFormStable [("is_test", "")]
549+-- "is_test"
550+--
551+-- Empty keys are allowed too:
552+--
553+-- >>> urlEncodeFormStable [("", "foobar")]
554+-- "=foobar"
555+--
556+-- However, if both key and value are empty, the key-value pair is ignored.
557+-- (This prevents @'urlDecodeForm' . 'urlEncodeFormStable'@ from being a true isomorphism).
558+--
559+-- >>> urlEncodeFormStable [("", "")]
560+-- ""
561+--
562+-- Everything is escaped with @'escapeURIString' 'isUnreserved'@:
563+--
564+-- >>> urlEncodeFormStable [("fullname", "Andres Löh")]
565+-- "fullname=Andres%20L%C3%B6h"
566+urlEncodeFormStable :: Form -> BSL.ByteString
567+urlEncodeFormStable = urlEncodeParams . sortOn fst . toList
568+
569+-- | Encode a list of key-value pairs to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
570+--
571+-- See also 'urlEncodeFormStable'.
572+urlEncodeParams :: [(Text, Text)] -> BSL.ByteString
573+urlEncodeParams = toLazyByteString . mconcat . intersperse (shortByteString "&") . map encodePair
574+ where
575+ escape = urlEncodeBuilder True . Text.encodeUtf8
576+
577+ encodePair (k, "") = escape k
578+ encodePair (k, v) = escape k <> shortByteString "=" <> escape v
579+
580+-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a 'Form'.
581+--
582+-- Key-value pairs get decoded normally:
583+--
584+-- >>> urlDecodeForm "name=Greg&lastname=Weber"
585+-- Right (fromList [("lastname","Weber"),("name","Greg")])
586+--
587+-- Keys with no values get decoded to pairs with empty values.
588+--
589+-- >>> urlDecodeForm "is_test"
590+-- Right (fromList [("is_test","")])
591+--
592+-- Empty keys are allowed:
593+--
594+-- >>> urlDecodeForm "=foobar"
595+-- Right (fromList [("","foobar")])
596+--
597+-- The empty string gets decoded into an empty 'Form':
598+--
599+-- >>> urlDecodeForm ""
600+-- Right (fromList [])
601+--
602+-- Everything is un-escaped with 'unEscapeString':
603+--
604+-- >>> urlDecodeForm "fullname=Andres%20L%C3%B6h"
605+-- Right (fromList [("fullname","Andres L\246h")])
606+--
607+-- Improperly formed strings result in an error:
608+--
609+-- >>> urlDecodeForm "this=has=too=many=equals"
610+-- Left "not a valid pair: this=has=too=many=equals"
611+urlDecodeForm :: BSL.ByteString -> Either Text Form
612+urlDecodeForm = fmap toForm . urlDecodeParams
613+
614+-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a list of key-value pairs.
615+--
616+-- See also 'urlDecodeForm'.
617+urlDecodeParams :: BSL.ByteString -> Either Text [(Text, Text)]
618+urlDecodeParams bs = traverse parsePair pairs
619+ where
620+ pairs = map (BSL8.split '=') (BSL8.split '&' bs)
621+
622+ unescape = Text.decodeUtf8With lenientDecode . urlDecode True . BSL.toStrict
623+
624+ parsePair p =
625+ case map unescape p of
626+ [k, v] -> return (k, v)
627+ [k] -> return (k, "")
628+ xs -> Left $ "not a valid pair: " <> Text.intercalate "=" xs
629+
630+
631+-- | This is a convenience function for decoding a
632+-- @application/x-www-form-urlencoded@ 'BSL.ByteString' directly to a datatype
633+-- that has an instance of 'FromForm'.
634+--
635+-- This is effectively @'fromForm' '<=<' 'urlDecodeForm'@.
636+--
637+-- >>> urlDecodeAsForm "name=Dennis&age=22" :: Either Text Person
638+-- Right (Person {name = "Dennis", age = 22})
639+urlDecodeAsForm :: FromForm a => BSL.ByteString -> Either Text a
640+urlDecodeAsForm = fromForm <=< urlDecodeForm
641+
642+-- | This is a convenience function for encoding a datatype that has instance
643+-- of 'ToForm' directly to a @application/x-www-form-urlencoded@
644+-- 'BSL.ByteString'.
645+--
646+-- This is effectively @'urlEncodeForm' . 'toForm'@.
647+--
648+-- _NOTE:_ this encoding is unstable and may result in different key order
649+-- (but not values). For a stable encoding see 'urlEncodeAsFormStable'.
650+urlEncodeAsForm :: ToForm a => a -> BSL.ByteString
651+urlEncodeAsForm = urlEncodeForm . toForm
652+
653+-- | This is a convenience function for encoding a datatype that has instance
654+-- of 'ToForm' directly to a @application/x-www-form-urlencoded@
655+-- 'BSL.ByteString'.
656+--
657+-- This is effectively @'urlEncodeFormStable' . 'toForm'@.
658+--
659+-- >>> urlEncodeAsFormStable Person {name = "Dennis", age = 22}
660+-- "age=22&name=Dennis"
661+urlEncodeAsFormStable :: ToForm a => a -> BSL.ByteString
662+urlEncodeAsFormStable = urlEncodeFormStable . toForm
663+
664+-- | Find all values corresponding to a given key in a 'Form'.
665+--
666+-- >>> lookupAll "name" []
667+-- []
668+-- >>> lookupAll "name" [("name", "Oleg")]
669+-- ["Oleg"]
670+-- >>> lookupAll "name" [("name", "Oleg"), ("name", "David")]
671+-- ["Oleg","David"]
672+lookupAll :: Text -> Form -> [Text]
673+lookupAll key = F.concat . HashMap.lookup key . unForm
674+
675+-- | Lookup an optional value for a key.
676+-- Fail if there is more than one value.
677+--
678+-- >>> lookupMaybe "name" []
679+-- Right Nothing
680+-- >>> lookupMaybe "name" [("name", "Oleg")]
681+-- Right (Just "Oleg")
682+-- >>> lookupMaybe "name" [("name", "Oleg"), ("name", "David")]
683+-- Left "Duplicate key \"name\""
684+lookupMaybe :: Text -> Form -> Either Text (Maybe Text)
685+lookupMaybe key form =
686+ case lookupAll key form of
687+ [] -> pure Nothing
688+ [v] -> pure (Just v)
689+ _ -> Left $ "Duplicate key " <> Text.pack (show key)
690+
691+-- | Lookup a unique value for a key.
692+-- Fail if there is zero or more than one value.
693+--
694+-- >>> lookupUnique "name" []
695+-- Left "Could not find key \"name\""
696+-- >>> lookupUnique "name" [("name", "Oleg")]
697+-- Right "Oleg"
698+-- >>> lookupUnique "name" [("name", "Oleg"), ("name", "David")]
699+-- Left "Duplicate key \"name\""
700+lookupUnique :: Text -> Form -> Either Text Text
701+lookupUnique key form = do
702+ mv <- lookupMaybe key form
703+ case mv of
704+ Just v -> pure v
705+ Nothing -> Left $ "Could not find key " <> Text.pack (show key)
706+
707+-- | Lookup all values for a given key in a 'Form' and parse them with 'parseQueryParams'.
708+--
709+-- >>> parseAll "age" [] :: Either Text [Word8]
710+-- Right []
711+-- >>> parseAll "age" [("age", "8"), ("age", "seven")] :: Either Text [Word8]
712+-- Left "could not parse: `seven' (input does not start with a digit)"
713+-- >>> parseAll "age" [("age", "8"), ("age", "777")] :: Either Text [Word8]
714+-- Left "out of bounds: `777' (should be between 0 and 255)"
715+-- >>> parseAll "age" [("age", "12"), ("age", "25")] :: Either Text [Word8]
716+-- Right [12,25]
717+parseAll :: FromHttpApiData v => Text -> Form -> Either Text [v]
718+parseAll key = parseQueryParams . lookupAll key
719+
720+-- | Lookup an optional value for a given key and parse it with 'parseQueryParam'.
721+-- Fail if there is more than one value for the key.
722+--
723+-- >>> parseMaybe "age" [] :: Either Text (Maybe Word8)
724+-- Right Nothing
725+-- >>> parseMaybe "age" [("age", "12"), ("age", "25")] :: Either Text (Maybe Word8)
726+-- Left "Duplicate key \"age\""
727+-- >>> parseMaybe "age" [("age", "seven")] :: Either Text (Maybe Word8)
728+-- Left "could not parse: `seven' (input does not start with a digit)"
729+-- >>> parseMaybe "age" [("age", "777")] :: Either Text (Maybe Word8)
730+-- Left "out of bounds: `777' (should be between 0 and 255)"
731+-- >>> parseMaybe "age" [("age", "7")] :: Either Text (Maybe Word8)
732+-- Right (Just 7)
733+parseMaybe :: FromHttpApiData v => Text -> Form -> Either Text (Maybe v)
734+parseMaybe key = parseQueryParams <=< lookupMaybe key
735+
736+-- | Lookup a unique value for a given key and parse it with 'parseQueryParam'.
737+-- Fail if there is zero or more than one value for the key.
738+--
739+-- >>> parseUnique "age" [] :: Either Text Word8
740+-- Left "Could not find key \"age\""
741+-- >>> parseUnique "age" [("age", "12"), ("age", "25")] :: Either Text Word8
742+-- Left "Duplicate key \"age\""
743+-- >>> parseUnique "age" [("age", "seven")] :: Either Text Word8
744+-- Left "could not parse: `seven' (input does not start with a digit)"
745+-- >>> parseUnique "age" [("age", "777")] :: Either Text Word8
746+-- Left "out of bounds: `777' (should be between 0 and 255)"
747+-- >>> parseUnique "age" [("age", "7")] :: Either Text Word8
748+-- Right 7
749+parseUnique :: FromHttpApiData v => Text -> Form -> Either Text v
750+parseUnique key form = lookupUnique key form >>= parseQueryParam
751+
752+-- | 'Generic'-based deriving options for 'ToForm' and 'FromForm'.
753+--
754+-- A common use case for non-default 'FormOptions'
755+-- is to strip a prefix off of field labels:
756+--
757+-- @
758+-- data Project = Project
759+-- { projectName :: String
760+-- , projectSize :: Int
761+-- } deriving ('Generic', 'Show')
762+--
763+-- myOptions :: 'FormOptions'
764+-- myOptions = 'FormOptions'
765+-- { 'fieldLabelModifier' = 'map' 'toLower' . 'drop' ('length' \"project\") }
766+--
767+-- instance 'ToForm' Project where
768+-- 'toForm' = 'genericToForm' myOptions
769+--
770+-- instance 'FromForm' Project where
771+-- 'fromForm' = 'genericFromForm' myOptions
772+-- @
773+--
774+-- >>> urlEncodeAsFormStable Project { projectName = "http-api-data", projectSize = 172 }
775+-- "name=http-api-data&size=172"
776+-- >>> urlDecodeAsForm "name=http-api-data&size=172" :: Either Text Project
777+-- Right (Project {projectName = "http-api-data", projectSize = 172})
778+data FormOptions = FormOptions
779+ { -- | Function applied to field labels. Handy for removing common record prefixes for example.
780+ fieldLabelModifier :: String -> String
781+ }
782+
783+-- | Default encoding 'FormOptions'.
784+--
785+-- @
786+-- 'FormOptions'
787+-- { 'fieldLabelModifier' = id
788+-- }
789+-- @
790+defaultFormOptions :: FormOptions
791+defaultFormOptions = FormOptions
792+ { fieldLabelModifier = id
793+ }
794+
795+sortOn :: Ord b => (a -> b) -> [a] -> [a]
796+sortOn f = sortBy (comparing f)
src/Web/Internal/HttpApiData.hsnew file+867
@@ -0,0 +1,867 @@
1+{-# LANGUAGE CPP #-}
2+{-# LANGUAGE DefaultSignatures #-}
3+{-# LANGUAGE DeriveDataTypeable #-}
4+{-# LANGUAGE DeriveFoldable #-}
5+{-# LANGUAGE DeriveFunctor #-}
6+{-# LANGUAGE DeriveTraversable #-}
7+{-# LANGUAGE FlexibleInstances #-}
8+{-# LANGUAGE KindSignatures #-}
9+{-# LANGUAGE OverloadedStrings #-}
10+{-# LANGUAGE PolyKinds #-}
11+{-# LANGUAGE ScopedTypeVariables #-}
12+{-# LANGUAGE TypeSynonymInstances #-}
13+-- |
14+-- Convert Haskell values to and from HTTP API data
15+-- such as URL pieces, headers and query parameters.
16+module Web.Internal.HttpApiData where
17+
18+import Prelude ()
19+import Prelude.Compat
20+
21+import Control.Applicative (Const(Const))
22+import Control.Arrow (left, (&&&))
23+import Control.Monad ((<=<))
24+import qualified Data.Attoparsec.Text as Atto
25+import qualified Data.Attoparsec.Time as Atto
26+import Data.ByteString (ByteString)
27+import qualified Data.ByteString as BS
28+import qualified Data.ByteString.Builder as BS
29+import qualified Data.ByteString.Lazy as LBS
30+import Data.Coerce (coerce)
31+import Data.Data (Data)
32+import qualified Data.Fixed as F
33+import Data.Functor.Identity (Identity(Identity))
34+import Data.Int (Int16, Int32, Int64, Int8)
35+import Data.Kind (Type)
36+import qualified Data.Map as Map
37+import Data.Monoid (All (..), Any (..), Dual (..),
38+ First (..), Last (..),
39+ Product (..), Sum (..))
40+import Data.Semigroup (Semigroup (..))
41+import qualified Data.Semigroup as Semi
42+import Data.Tagged (Tagged (..))
43+import Data.Text (Text)
44+import qualified Data.Text as T
45+import Data.Text.Encoding (decodeUtf8', decodeUtf8With,
46+ encodeUtf8)
47+import Data.Text.Encoding.Error (lenientDecode)
48+import qualified Data.Text.Lazy as L
49+import Data.Text.Read (Reader, decimal, rational,
50+ signed)
51+import Data.Time.Compat (Day, FormatTime, LocalTime,
52+ NominalDiffTime, TimeOfDay,
53+ UTCTime, ZonedTime, formatTime,
54+ DayOfWeek (..),
55+ nominalDiffTimeToSeconds,
56+ secondsToNominalDiffTime)
57+import Data.Time.Format.Compat (defaultTimeLocale,
58+ iso8601DateFormat)
59+import Data.Time.Calendar.Month.Compat (Month)
60+import Data.Time.Calendar.Quarter.Compat (Quarter, QuarterOfYear (..),
61+ toYearQuarter)
62+import Data.Typeable (Typeable)
63+import qualified Data.UUID.Types as UUID
64+import Data.Version (Version, parseVersion,
65+ showVersion)
66+import Data.Void (Void, absurd)
67+import Data.Word (Word16, Word32, Word64, Word8)
68+import qualified Network.HTTP.Types as H
69+import Numeric.Natural (Natural)
70+import Text.ParserCombinators.ReadP (readP_to_S)
71+import Text.Read (readMaybe)
72+import Web.Cookie (SetCookie, parseSetCookie,
73+ renderSetCookie)
74+
75+#if USE_TEXT_SHOW
76+import TextShow (TextShow, showt)
77+#endif
78+
79+-- $setup
80+-- >>> data BasicAuthToken = BasicAuthToken Text deriving (Show)
81+-- >>> instance FromHttpApiData BasicAuthToken where parseHeader h = BasicAuthToken <$> parseHeaderWithPrefix "Basic " h; parseQueryParam p = BasicAuthToken <$> parseQueryParam p
82+-- >>> import Data.Time.Compat
83+-- >>> import Data.Version
84+
85+-- | Convert value to HTTP API data.
86+--
87+-- __WARNING__: Do not derive this using @DeriveAnyClass@ as the generated
88+-- instance will loop indefinitely.
89+class ToHttpApiData a where
90+ {-# MINIMAL toUrlPiece | toQueryParam #-}
91+ -- | Convert to URL path piece.
92+ toUrlPiece :: a -> Text
93+ toUrlPiece = toQueryParam
94+
95+ -- | Convert to a URL path piece, making sure to encode any special chars.
96+ -- The default definition uses 'H.encodePathSegmentsRelative',
97+ -- but this may be overriden with a more efficient version.
98+ toEncodedUrlPiece :: a -> BS.Builder
99+ toEncodedUrlPiece = H.urlEncodeBuilder True . encodeUtf8 . toUrlPiece
100+
101+ -- | Convert to HTTP header value.
102+ toHeader :: a -> ByteString
103+ toHeader = encodeUtf8 . toUrlPiece
104+
105+ -- | Convert to query param value.
106+ toQueryParam :: a -> Text
107+ toQueryParam = toUrlPiece
108+
109+-- | Parse value from HTTP API data.
110+--
111+-- __WARNING__: Do not derive this using @DeriveAnyClass@ as the generated
112+-- instance will loop indefinitely.
113+class FromHttpApiData a where
114+ {-# MINIMAL parseUrlPiece | parseQueryParam #-}
115+ -- | Parse URL path piece.
116+ parseUrlPiece :: Text -> Either Text a
117+ parseUrlPiece = parseQueryParam
118+
119+ -- | Parse HTTP header value.
120+ parseHeader :: ByteString -> Either Text a
121+ parseHeader = parseUrlPiece <=< (left (T.pack . show) . decodeUtf8')
122+
123+ -- | Parse query param value.
124+ parseQueryParam :: Text -> Either Text a
125+ parseQueryParam = parseUrlPiece
126+
127+-- | Convert multiple values to a list of URL pieces.
128+--
129+-- >>> toUrlPieces [1, 2, 3] :: [Text]
130+-- ["1","2","3"]
131+toUrlPieces :: (Functor t, ToHttpApiData a) => t a -> t Text
132+toUrlPieces = fmap toUrlPiece
133+
134+-- | Parse multiple URL pieces.
135+--
136+-- >>> parseUrlPieces ["true", "false"] :: Either Text [Bool]
137+-- Right [True,False]
138+-- >>> parseUrlPieces ["123", "hello", "world"] :: Either Text [Int]
139+-- Left "could not parse: `hello' (input does not start with a digit)"
140+parseUrlPieces :: (Traversable t, FromHttpApiData a) => t Text -> Either Text (t a)
141+parseUrlPieces = traverse parseUrlPiece
142+
143+-- | Convert multiple values to a list of query parameter values.
144+--
145+-- >>> toQueryParams [fromGregorian 2015 10 03, fromGregorian 2015 12 01] :: [Text]
146+-- ["2015-10-03","2015-12-01"]
147+toQueryParams :: (Functor t, ToHttpApiData a) => t a -> t Text
148+toQueryParams = fmap toQueryParam
149+
150+-- | Parse multiple query parameters.
151+--
152+-- >>> parseQueryParams ["1", "2", "3"] :: Either Text [Int]
153+-- Right [1,2,3]
154+-- >>> parseQueryParams ["64", "128", "256"] :: Either Text [Word8]
155+-- Left "out of bounds: `256' (should be between 0 and 255)"
156+parseQueryParams :: (Traversable t, FromHttpApiData a) => t Text -> Either Text (t a)
157+parseQueryParams = traverse parseQueryParam
158+
159+-- | Parse URL path piece in a @'Maybe'@.
160+--
161+-- >>> parseUrlPieceMaybe "12" :: Maybe Int
162+-- Just 12
163+parseUrlPieceMaybe :: FromHttpApiData a => Text -> Maybe a
164+parseUrlPieceMaybe = either (const Nothing) Just . parseUrlPiece
165+
166+-- | Parse HTTP header value in a @'Maybe'@.
167+--
168+-- >>> parseHeaderMaybe "hello" :: Maybe Text
169+-- Just "hello"
170+parseHeaderMaybe :: FromHttpApiData a => ByteString -> Maybe a
171+parseHeaderMaybe = either (const Nothing) Just . parseHeader
172+
173+-- | Parse query param value in a @'Maybe'@.
174+--
175+-- >>> parseQueryParamMaybe "true" :: Maybe Bool
176+-- Just True
177+parseQueryParamMaybe :: FromHttpApiData a => Text -> Maybe a
178+parseQueryParamMaybe = either (const Nothing) Just . parseQueryParam
179+
180+-- | Default parsing error.
181+defaultParseError :: Text -> Either Text a
182+defaultParseError input = Left ("could not parse: `" <> input <> "'")
183+
184+-- | Convert @'Maybe'@ parser into @'Either' 'Text'@ parser with default error message.
185+parseMaybeTextData :: (Text -> Maybe a) -> (Text -> Either Text a)
186+parseMaybeTextData parse input =
187+ case parse input of
188+ Nothing -> defaultParseError input
189+ Just val -> Right val
190+
191+#if USE_TEXT_SHOW
192+-- | /Lower case/.
193+--
194+-- Convert to URL piece using @'TextShow'@ instance.
195+-- The result is always lower cased.
196+--
197+-- >>> showTextData True
198+-- "true"
199+--
200+-- This can be used as a default implementation for enumeration types:
201+--
202+-- @
203+-- data MyData = Foo | Bar | Baz deriving (Generic)
204+--
205+-- instance TextShow MyData where
206+-- showt = genericShowt
207+--
208+-- instance ToHttpApiData MyData where
209+-- toUrlPiece = showTextData
210+-- @
211+showTextData :: TextShow a => a -> Text
212+showTextData = T.toLower . showt
213+#else
214+-- | /Lower case/.
215+--
216+-- Convert to URL piece using @'Show'@ instance.
217+-- The result is always lower cased.
218+--
219+-- >>> showTextData True
220+-- "true"
221+--
222+-- This can be used as a default implementation for enumeration types:
223+--
224+-- >>> data MyData = Foo | Bar | Baz deriving (Show)
225+-- >>> instance ToHttpApiData MyData where toUrlPiece = showTextData
226+-- >>> toUrlPiece Foo
227+-- "foo"
228+showTextData :: Show a => a -> Text
229+showTextData = T.toLower . showt
230+
231+-- | Like @'show'@, but returns @'Text'@.
232+showt :: Show a => a -> Text
233+showt = T.pack . show
234+#endif
235+
236+-- | /Case insensitive/.
237+--
238+-- Parse given text case insensitive and then parse the rest of the input
239+-- using @'parseUrlPiece'@.
240+--
241+-- >>> parseUrlPieceWithPrefix "Just " "just 10" :: Either Text Int
242+-- Right 10
243+-- >>> parseUrlPieceWithPrefix "Left " "left" :: Either Text Bool
244+-- Left "could not parse: `left'"
245+--
246+-- This can be used to implement @'FromHttpApiData'@ for single field constructors:
247+--
248+-- >>> data Foo = Foo Int deriving (Show)
249+-- >>> instance FromHttpApiData Foo where parseUrlPiece s = Foo <$> parseUrlPieceWithPrefix "Foo " s
250+-- >>> parseUrlPiece "foo 1" :: Either Text Foo
251+-- Right (Foo 1)
252+parseUrlPieceWithPrefix :: FromHttpApiData a => Text -> Text -> Either Text a
253+parseUrlPieceWithPrefix pattern input
254+ | T.toLower pattern == T.toLower prefix = parseUrlPiece rest
255+ | otherwise = defaultParseError input
256+ where
257+ (prefix, rest) = T.splitAt (T.length pattern) input
258+
259+-- | Parse given bytestring then parse the rest of the input using @'parseHeader'@.
260+--
261+-- @
262+-- data BasicAuthToken = BasicAuthToken Text deriving (Show)
263+--
264+-- instance FromHttpApiData BasicAuthToken where
265+-- parseHeader h = BasicAuthToken \<$\> parseHeaderWithPrefix "Basic " h
266+-- parseQueryParam p = BasicAuthToken \<$\> parseQueryParam p
267+-- @
268+--
269+-- >>> parseHeader "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" :: Either Text BasicAuthToken
270+-- Right (BasicAuthToken "QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
271+parseHeaderWithPrefix :: FromHttpApiData a => ByteString -> ByteString -> Either Text a
272+parseHeaderWithPrefix pattern input
273+ | pattern `BS.isPrefixOf` input = parseHeader (BS.drop (BS.length pattern) input)
274+ | otherwise = defaultParseError (showt input)
275+
276+-- | /Case insensitive/.
277+--
278+-- Parse given text case insensitive and then parse the rest of the input
279+-- using @'parseQueryParam'@.
280+--
281+-- >>> parseQueryParamWithPrefix "z" "z10" :: Either Text Int
282+-- Right 10
283+parseQueryParamWithPrefix :: FromHttpApiData a => Text -> Text -> Either Text a
284+parseQueryParamWithPrefix pattern input
285+ | T.toLower pattern == T.toLower prefix = parseQueryParam rest
286+ | otherwise = defaultParseError input
287+ where
288+ (prefix, rest) = T.splitAt (T.length pattern) input
289+
290+#if USE_TEXT_SHOW
291+-- | /Case insensitive/.
292+--
293+-- Parse values case insensitively based on @'TextShow'@ instance.
294+--
295+-- >>> parseBoundedTextData "true" :: Either Text Bool
296+-- Right True
297+-- >>> parseBoundedTextData "FALSE" :: Either Text Bool
298+-- Right False
299+--
300+-- This can be used as a default implementation for enumeration types:
301+--
302+-- @
303+-- data MyData = Foo | Bar | Baz deriving (Show, Bounded, Enum, Generic)
304+--
305+-- instance TextShow MyData where
306+-- showt = genericShowt
307+--
308+-- instance FromHttpApiData MyData where
309+-- parseUrlPiece = parseBoundedTextData
310+-- @
311+parseBoundedTextData :: (TextShow a, Bounded a, Enum a) => Text -> Either Text a
312+#else
313+-- | /Case insensitive/.
314+--
315+-- Parse values case insensitively based on @'Show'@ instance.
316+--
317+-- >>> parseBoundedTextData "true" :: Either Text Bool
318+-- Right True
319+-- >>> parseBoundedTextData "FALSE" :: Either Text Bool
320+-- Right False
321+--
322+-- This can be used as a default implementation for enumeration types:
323+--
324+-- >>> data MyData = Foo | Bar | Baz deriving (Show, Bounded, Enum)
325+-- >>> instance FromHttpApiData MyData where parseUrlPiece = parseBoundedTextData
326+-- >>> parseUrlPiece "foo" :: Either Text MyData
327+-- Right Foo
328+parseBoundedTextData :: (Show a, Bounded a, Enum a) => Text -> Either Text a
329+#endif
330+parseBoundedTextData = parseBoundedEnumOfI showTextData
331+
332+-- | Lookup values based on a precalculated mapping of their representations.
333+lookupBoundedEnumOf :: (Bounded a, Enum a, Eq b) => (a -> b) -> b -> Maybe a
334+lookupBoundedEnumOf f = flip lookup (map (f &&& id) [minBound..maxBound])
335+
336+-- | Parse values based on a precalculated mapping of their @'Text'@ representation.
337+--
338+-- >>> parseBoundedEnumOf toUrlPiece "true" :: Either Text Bool
339+-- Right True
340+--
341+-- For case insensitive parser see 'parseBoundedEnumOfI'.
342+parseBoundedEnumOf :: (Bounded a, Enum a) => (a -> Text) -> Text -> Either Text a
343+parseBoundedEnumOf = parseMaybeTextData . lookupBoundedEnumOf
344+
345+-- | /Case insensitive/.
346+--
347+-- Parse values case insensitively based on a precalculated mapping
348+-- of their @'Text'@ representations.
349+--
350+-- >>> parseBoundedEnumOfI toUrlPiece "FALSE" :: Either Text Bool
351+-- Right False
352+--
353+-- For case sensitive parser see 'parseBoundedEnumOf'.
354+parseBoundedEnumOfI :: (Bounded a, Enum a) => (a -> Text) -> Text -> Either Text a
355+parseBoundedEnumOfI f = parseBoundedEnumOf (T.toLower . f) . T.toLower
356+
357+-- | /Case insensitive/.
358+--
359+-- Parse values case insensitively based on @'ToHttpApiData'@ instance.
360+-- Uses @'toUrlPiece'@ to get possible values.
361+parseBoundedUrlPiece :: (ToHttpApiData a, Bounded a, Enum a) => Text -> Either Text a
362+parseBoundedUrlPiece = parseBoundedEnumOfI toUrlPiece
363+
364+-- | /Case insensitive/.
365+--
366+-- Parse values case insensitively based on @'ToHttpApiData'@ instance.
367+-- Uses @'toQueryParam'@ to get possible values.
368+parseBoundedQueryParam :: (ToHttpApiData a, Bounded a, Enum a) => Text -> Either Text a
369+parseBoundedQueryParam = parseBoundedEnumOfI toQueryParam
370+
371+-- | Parse values based on @'ToHttpApiData'@ instance.
372+-- Uses @'toHeader'@ to get possible values.
373+parseBoundedHeader :: (ToHttpApiData a, Bounded a, Enum a) => ByteString -> Either Text a
374+parseBoundedHeader bs = case lookupBoundedEnumOf toHeader bs of
375+ Nothing -> defaultParseError $ T.pack $ show bs
376+ Just x -> return x
377+
378+-- | Parse URL piece using @'Read'@ instance.
379+--
380+-- Use for types which do not involve letters:
381+--
382+-- >>> readTextData "1991-06-02" :: Either Text Day
383+-- Right 1991-06-02
384+--
385+-- This parser is case sensitive and will not match @'showTextData'@
386+-- in presence of letters:
387+--
388+-- >>> readTextData (showTextData True) :: Either Text Bool
389+-- Left "could not parse: `true'"
390+--
391+-- See @'parseBoundedTextData'@.
392+readTextData :: Read a => Text -> Either Text a
393+readTextData = parseMaybeTextData (readMaybe . T.unpack)
394+
395+-- | Run @'Reader'@ as HTTP API data parser.
396+runReader :: Reader a -> Text -> Either Text a
397+runReader reader input =
398+ case reader input of
399+ Left err -> Left ("could not parse: `" <> input <> "' (" <> T.pack err <> ")")
400+ Right (x, rest)
401+ | T.null rest -> Right x
402+ | otherwise -> defaultParseError input
403+
404+-- | Run @'Reader'@ to parse bounded integral value with bounds checking.
405+--
406+-- >>> parseBounded decimal "256" :: Either Text Word8
407+-- Left "out of bounds: `256' (should be between 0 and 255)"
408+parseBounded :: forall a. (Bounded a, Integral a) => Reader Integer -> Text -> Either Text a
409+parseBounded reader input = do
410+ n <- runReader reader input
411+ if (n > h || n < l)
412+ then Left ("out of bounds: `" <> input <> "' (should be between " <> showt l <> " and " <> showt h <> ")")
413+ else Right (fromInteger n)
414+ where
415+ l = toInteger (minBound :: a)
416+ h = toInteger (maxBound :: a)
417+
418+-- | Convert to a URL-encoded path piece using 'toUrlPiece'.
419+-- /Note/: this function does not check if the result contains unescaped characters!
420+-- This function can be used to override 'toEncodedUrlPiece' as a more efficient implementation
421+-- when the resulting URL piece /never/ has to be escaped.
422+unsafeToEncodedUrlPiece :: ToHttpApiData a => a -> BS.Builder
423+unsafeToEncodedUrlPiece = BS.byteString . encodeUtf8 . toUrlPiece
424+
425+-- |
426+-- >>> toUrlPiece ()
427+-- "_"
428+instance ToHttpApiData () where
429+ toUrlPiece () = "_"
430+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
431+
432+instance ToHttpApiData Char where
433+ toUrlPiece = T.singleton
434+
435+-- |
436+-- >>> toUrlPiece (Version [1, 2, 3] [])
437+-- "1.2.3"
438+instance ToHttpApiData Version where
439+ toUrlPiece = T.pack . showVersion
440+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
441+
442+instance ToHttpApiData Void where toUrlPiece = absurd
443+instance ToHttpApiData Natural where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
444+
445+instance ToHttpApiData Bool where toUrlPiece = showTextData; toEncodedUrlPiece = unsafeToEncodedUrlPiece
446+instance ToHttpApiData Ordering where toUrlPiece = showTextData; toEncodedUrlPiece = unsafeToEncodedUrlPiece
447+
448+instance ToHttpApiData Double where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
449+instance ToHttpApiData Float where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
450+instance ToHttpApiData Int where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
451+instance ToHttpApiData Int8 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
452+instance ToHttpApiData Int16 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
453+instance ToHttpApiData Int32 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
454+instance ToHttpApiData Int64 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
455+instance ToHttpApiData Integer where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
456+instance ToHttpApiData Word where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
457+instance ToHttpApiData Word8 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
458+instance ToHttpApiData Word16 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
459+instance ToHttpApiData Word32 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
460+instance ToHttpApiData Word64 where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
461+
462+-- | Note: this instance is not polykinded
463+instance F.HasResolution a => ToHttpApiData (F.Fixed (a :: Type)) where toUrlPiece = showt; toEncodedUrlPiece = unsafeToEncodedUrlPiece
464+
465+-- |
466+-- >>> toUrlPiece (fromGregorian 2015 10 03)
467+-- "2015-10-03"
468+instance ToHttpApiData Day where
469+ toUrlPiece = T.pack . show
470+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
471+
472+timeToUrlPiece :: FormatTime t => String -> t -> Text
473+timeToUrlPiece fmt = T.pack . formatTime defaultTimeLocale (iso8601DateFormat (Just fmt))
474+
475+-- |
476+-- >>> toUrlPiece $ TimeOfDay 14 55 23.1
477+-- "14:55:23.1"
478+instance ToHttpApiData TimeOfDay where
479+ toUrlPiece = T.pack . formatTime defaultTimeLocale "%H:%M:%S%Q"
480+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
481+
482+-- |
483+-- >>> toUrlPiece $ LocalTime (fromGregorian 2015 10 03) (TimeOfDay 14 55 21.687)
484+-- "2015-10-03T14:55:21.687"
485+instance ToHttpApiData LocalTime where
486+ toUrlPiece = timeToUrlPiece "%H:%M:%S%Q"
487+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
488+
489+-- |
490+-- >>> toUrlPiece $ ZonedTime (LocalTime (fromGregorian 2015 10 03) (TimeOfDay 14 55 51.001)) utc
491+-- "2015-10-03T14:55:51.001+0000"
492+instance ToHttpApiData ZonedTime where
493+ toUrlPiece = timeToUrlPiece "%H:%M:%S%Q%z"
494+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
495+
496+-- |
497+-- >>> toUrlPiece $ UTCTime (fromGregorian 2015 10 03) 864.5
498+-- "2015-10-03T00:14:24.5Z"
499+instance ToHttpApiData UTCTime where
500+ toUrlPiece = timeToUrlPiece "%H:%M:%S%QZ"
501+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
502+
503+-- |
504+-- >>> toUrlPiece Monday
505+-- "monday"
506+instance ToHttpApiData DayOfWeek where
507+ toUrlPiece Monday = "monday"
508+ toUrlPiece Tuesday = "tuesday"
509+ toUrlPiece Wednesday = "wednesday"
510+ toUrlPiece Thursday = "thursday"
511+ toUrlPiece Friday = "friday"
512+ toUrlPiece Saturday = "saturday"
513+ toUrlPiece Sunday = "sunday"
514+
515+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
516+
517+-- |
518+-- >>> toUrlPiece Q4
519+-- "q4"
520+instance ToHttpApiData QuarterOfYear where
521+ toUrlPiece Q1 = "q1"
522+ toUrlPiece Q2 = "q2"
523+ toUrlPiece Q3 = "q3"
524+ toUrlPiece Q4 = "q4"
525+
526+-- |
527+-- >>> import Data.Time.Calendar.Quarter.Compat (Quarter (..))
528+-- >>> MkQuarter 8040
529+-- 2010-Q1
530+--
531+-- >>> toUrlPiece $ MkQuarter 8040
532+-- "2010-q1"
533+--
534+instance ToHttpApiData Quarter where
535+ toUrlPiece q = case toYearQuarter q of
536+ (y, qoy) -> T.pack (show y ++ "-" ++ f qoy)
537+ where
538+ f Q1 = "q1"
539+ f Q2 = "q2"
540+ f Q3 = "q3"
541+ f Q4 = "q4"
542+
543+-- |
544+-- >>> import Data.Time.Calendar.Month.Compat (Month (..))
545+-- >>> MkMonth 24482
546+-- 2040-03
547+--
548+-- >>> toUrlPiece $ MkMonth 24482
549+-- "2040-03"
550+--
551+instance ToHttpApiData Month where
552+ toUrlPiece = T.pack . formatTime defaultTimeLocale "%Y-%m"
553+
554+instance ToHttpApiData NominalDiffTime where
555+ toUrlPiece = toUrlPiece . nominalDiffTimeToSeconds
556+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
557+
558+instance ToHttpApiData String where toUrlPiece = T.pack
559+instance ToHttpApiData Text where toUrlPiece = id
560+instance ToHttpApiData L.Text where toUrlPiece = L.toStrict
561+
562+instance ToHttpApiData All where
563+ toUrlPiece = coerce (toUrlPiece :: Bool -> Text)
564+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: Bool -> BS.Builder)
565+
566+instance ToHttpApiData Any where
567+ toUrlPiece = coerce (toUrlPiece :: Bool -> Text)
568+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: Bool -> BS.Builder)
569+
570+instance ToHttpApiData a => ToHttpApiData (Dual a) where
571+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
572+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
573+
574+instance ToHttpApiData a => ToHttpApiData (Sum a) where
575+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
576+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
577+
578+instance ToHttpApiData a => ToHttpApiData (Product a) where
579+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
580+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
581+
582+instance ToHttpApiData a => ToHttpApiData (First a) where
583+ toUrlPiece = coerce (toUrlPiece :: Maybe a -> Text)
584+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: Maybe a -> BS.Builder)
585+
586+instance ToHttpApiData a => ToHttpApiData (Last a) where
587+ toUrlPiece = coerce (toUrlPiece :: Maybe a -> Text)
588+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: Maybe a -> BS.Builder)
589+
590+instance ToHttpApiData a => ToHttpApiData (Semi.Min a) where
591+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
592+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
593+
594+instance ToHttpApiData a => ToHttpApiData (Semi.Max a) where
595+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
596+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
597+
598+instance ToHttpApiData a => ToHttpApiData (Semi.First a) where
599+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
600+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
601+
602+instance ToHttpApiData a => ToHttpApiData (Semi.Last a) where
603+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
604+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
605+
606+-- |
607+-- >>> toUrlPiece (Just "Hello")
608+-- "just Hello"
609+instance ToHttpApiData a => ToHttpApiData (Maybe a) where
610+ toUrlPiece (Just x) = "just " <> toUrlPiece x
611+ toUrlPiece Nothing = "nothing"
612+
613+-- |
614+-- >>> toUrlPiece (Left "err" :: Either String Int)
615+-- "left err"
616+-- >>> toUrlPiece (Right 3 :: Either String Int)
617+-- "right 3"
618+instance (ToHttpApiData a, ToHttpApiData b) => ToHttpApiData (Either a b) where
619+ toUrlPiece (Left x) = "left " <> toUrlPiece x
620+ toUrlPiece (Right x) = "right " <> toUrlPiece x
621+
622+-- | /Note:/ this instance works correctly for alphanumeric name and value
623+--
624+-- >>> let Right c = parseUrlPiece "SESSID=r2t5uvjq435r4q7ib3vtdjq120" :: Either Text SetCookie
625+-- >>> toUrlPiece c
626+-- "SESSID=r2t5uvjq435r4q7ib3vtdjq120"
627+--
628+-- >>> toHeader c
629+-- "SESSID=r2t5uvjq435r4q7ib3vtdjq120"
630+--
631+instance ToHttpApiData SetCookie where
632+ toUrlPiece = decodeUtf8With lenientDecode . toHeader
633+ toHeader = LBS.toStrict . BS.toLazyByteString . renderSetCookie
634+ -- toEncodedUrlPiece = renderSetCookie -- doesn't do things.
635+
636+-- | Note: this instance is not polykinded
637+instance ToHttpApiData a => ToHttpApiData (Tagged (b :: Type) a) where
638+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
639+ toHeader = coerce (toHeader :: a -> ByteString)
640+ toQueryParam = coerce (toQueryParam :: a -> Text)
641+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
642+
643+-- | @since 0.4.2
644+instance ToHttpApiData a => ToHttpApiData (Const a b) where
645+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
646+ toHeader = coerce (toHeader :: a -> ByteString)
647+ toQueryParam = coerce (toQueryParam :: a -> Text)
648+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
649+
650+-- | @since 0.4.2
651+instance ToHttpApiData a => ToHttpApiData (Identity a) where
652+ toUrlPiece = coerce (toUrlPiece :: a -> Text)
653+ toHeader = coerce (toHeader :: a -> ByteString)
654+ toQueryParam = coerce (toQueryParam :: a -> Text)
655+ toEncodedUrlPiece = coerce (toEncodedUrlPiece :: a -> BS.Builder)
656+
657+-- |
658+-- >>> parseUrlPiece "_" :: Either Text ()
659+-- Right ()
660+instance FromHttpApiData () where
661+ parseUrlPiece "_" = pure ()
662+ parseUrlPiece s = defaultParseError s
663+
664+instance FromHttpApiData Char where
665+ parseUrlPiece s =
666+ case T.uncons s of
667+ Just (c, s') | T.null s' -> pure c
668+ _ -> defaultParseError s
669+
670+-- |
671+-- >>> showVersion <$> parseUrlPiece "1.2.3"
672+-- Right "1.2.3"
673+instance FromHttpApiData Version where
674+ parseUrlPiece s =
675+ case reverse (readP_to_S parseVersion (T.unpack s)) of
676+ ((x, ""):_) -> pure x
677+ _ -> defaultParseError s
678+
679+-- | Parsing a @'Void'@ value is always an error, considering @'Void'@ as a data type with no constructors.
680+instance FromHttpApiData Void where
681+ parseUrlPiece _ = Left "Void cannot be parsed!"
682+
683+instance FromHttpApiData Natural where
684+ parseUrlPiece s = do
685+ n <- runReader (signed decimal) s
686+ if n < 0
687+ then Left ("underflow: " <> s <> " (should be a non-negative integer)")
688+ else Right (fromInteger n)
689+
690+instance FromHttpApiData Bool where parseUrlPiece = parseBoundedUrlPiece
691+instance FromHttpApiData Ordering where parseUrlPiece = parseBoundedUrlPiece
692+instance FromHttpApiData Double where parseUrlPiece = runReader rational
693+instance FromHttpApiData Float where parseUrlPiece = runReader rational
694+instance FromHttpApiData Int where parseUrlPiece = parseBounded (signed decimal)
695+instance FromHttpApiData Int8 where parseUrlPiece = parseBounded (signed decimal)
696+instance FromHttpApiData Int16 where parseUrlPiece = parseBounded (signed decimal)
697+instance FromHttpApiData Int32 where parseUrlPiece = parseBounded (signed decimal)
698+instance FromHttpApiData Int64 where parseUrlPiece = parseBounded (signed decimal)
699+instance FromHttpApiData Integer where parseUrlPiece = runReader (signed decimal)
700+instance FromHttpApiData Word where parseUrlPiece = parseBounded decimal
701+instance FromHttpApiData Word8 where parseUrlPiece = parseBounded decimal
702+instance FromHttpApiData Word16 where parseUrlPiece = parseBounded decimal
703+instance FromHttpApiData Word32 where parseUrlPiece = parseBounded decimal
704+instance FromHttpApiData Word64 where parseUrlPiece = parseBounded decimal
705+instance FromHttpApiData String where parseUrlPiece = Right . T.unpack
706+instance FromHttpApiData Text where parseUrlPiece = Right
707+instance FromHttpApiData L.Text where parseUrlPiece = Right . L.fromStrict
708+
709+-- | Note: this instance is not polykinded
710+instance F.HasResolution a => FromHttpApiData (F.Fixed (a :: Type)) where
711+ parseUrlPiece = runReader rational
712+
713+-- |
714+-- >>> toGregorian <$> parseUrlPiece "2016-12-01"
715+-- Right (2016,12,1)
716+instance FromHttpApiData Day where parseUrlPiece = runAtto Atto.day
717+
718+-- |
719+-- >>> parseUrlPiece "14:55:01.333" :: Either Text TimeOfDay
720+-- Right 14:55:01.333
721+instance FromHttpApiData TimeOfDay where parseUrlPiece = runAtto Atto.timeOfDay
722+
723+-- |
724+-- >>> parseUrlPiece "2015-10-03T14:55:01" :: Either Text LocalTime
725+-- Right 2015-10-03 14:55:01
726+instance FromHttpApiData LocalTime where parseUrlPiece = runAtto Atto.localTime
727+
728+-- |
729+-- >>> parseUrlPiece "2015-10-03T14:55:01+0000" :: Either Text ZonedTime
730+-- Right 2015-10-03 14:55:01 +0000
731+--
732+-- >>> parseQueryParam "2016-12-31T01:00:00Z" :: Either Text ZonedTime
733+-- Right 2016-12-31 01:00:00 +0000
734+instance FromHttpApiData ZonedTime where parseUrlPiece = runAtto Atto.zonedTime
735+
736+-- |
737+-- >>> parseUrlPiece "2015-10-03T00:14:24Z" :: Either Text UTCTime
738+-- Right 2015-10-03 00:14:24 UTC
739+instance FromHttpApiData UTCTime where parseUrlPiece = runAtto Atto.utcTime
740+
741+-- |
742+-- >>> parseUrlPiece "Monday" :: Either Text DayOfWeek
743+-- Right Monday
744+instance FromHttpApiData DayOfWeek where
745+ parseUrlPiece t = case Map.lookup (T.toLower t) m of
746+ Just dow -> Right dow
747+ Nothing -> Left $ "Incorrect DayOfWeek: " <> T.take 10 t
748+ where
749+ m :: Map.Map Text DayOfWeek
750+ m = Map.fromList [ (toUrlPiece dow, dow) | dow <- [Monday .. Sunday] ]
751+
752+
753+instance FromHttpApiData NominalDiffTime where parseUrlPiece = fmap secondsToNominalDiffTime . parseUrlPiece
754+
755+-- |
756+-- >>> parseUrlPiece "2021-01" :: Either Text Month
757+-- Right 2021-01
758+instance FromHttpApiData Month where parseUrlPiece = runAtto Atto.month
759+
760+-- |
761+-- >>> parseUrlPiece "2021-q1" :: Either Text Quarter
762+-- Right 2021-Q1
763+instance FromHttpApiData Quarter where parseUrlPiece = runAtto Atto.quarter
764+
765+-- |
766+-- >>> parseUrlPiece "q2" :: Either Text QuarterOfYear
767+-- Right Q2
768+--
769+-- >>> parseUrlPiece "Q3" :: Either Text QuarterOfYear
770+-- Right Q3
771+instance FromHttpApiData QuarterOfYear where
772+ parseUrlPiece t = case T.toLower t of
773+ "q1" -> return Q1
774+ "q2" -> return Q2
775+ "q3" -> return Q3
776+ "q4" -> return Q4
777+ _ -> Left "Invalid quarter of year"
778+
779+instance FromHttpApiData All where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text Bool)
780+instance FromHttpApiData Any where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text Bool)
781+
782+instance FromHttpApiData a => FromHttpApiData (Dual a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
783+instance FromHttpApiData a => FromHttpApiData (Sum a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
784+instance FromHttpApiData a => FromHttpApiData (Product a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
785+instance FromHttpApiData a => FromHttpApiData (First a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text (Maybe a))
786+instance FromHttpApiData a => FromHttpApiData (Last a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text (Maybe a))
787+
788+instance FromHttpApiData a => FromHttpApiData (Semi.Min a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
789+instance FromHttpApiData a => FromHttpApiData (Semi.Max a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
790+instance FromHttpApiData a => FromHttpApiData (Semi.First a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
791+instance FromHttpApiData a => FromHttpApiData (Semi.Last a) where parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
792+
793+-- |
794+-- >>> parseUrlPiece "Just 123" :: Either Text (Maybe Int)
795+-- Right (Just 123)
796+instance FromHttpApiData a => FromHttpApiData (Maybe a) where
797+ parseUrlPiece s
798+ | T.toLower (T.take 7 s) == "nothing" = pure Nothing
799+ | otherwise = Just <$> parseUrlPieceWithPrefix "Just " s
800+
801+-- |
802+-- >>> parseUrlPiece "Right 123" :: Either Text (Either String Int)
803+-- Right (Right 123)
804+instance (FromHttpApiData a, FromHttpApiData b) => FromHttpApiData (Either a b) where
805+ parseUrlPiece s =
806+ Right <$> parseUrlPieceWithPrefix "Right " s
807+ <!> Left <$> parseUrlPieceWithPrefix "Left " s
808+ where
809+ infixl 3 <!>
810+ Left _ <!> y = y
811+ x <!> _ = x
812+
813+instance ToHttpApiData UUID.UUID where
814+ toUrlPiece = UUID.toText
815+ toHeader = UUID.toASCIIBytes
816+ toEncodedUrlPiece = unsafeToEncodedUrlPiece
817+
818+instance FromHttpApiData UUID.UUID where
819+ parseUrlPiece = maybe (Left "invalid UUID") Right . UUID.fromText
820+ parseHeader = maybe (Left "invalid UUID") Right . UUID.fromASCIIBytes
821+
822+
823+-- | Lenient parameters. 'FromHttpApiData' combinators always return `Right`.
824+--
825+-- @since 0.3.5
826+newtype LenientData a = LenientData { getLenientData :: Either Text a }
827+ deriving (Eq, Ord, Show, Read, Typeable, Data, Functor, Foldable, Traversable)
828+
829+instance FromHttpApiData a => FromHttpApiData (LenientData a) where
830+ parseUrlPiece = Right . LenientData . parseUrlPiece
831+ parseHeader = Right . LenientData . parseHeader
832+ parseQueryParam = Right . LenientData . parseQueryParam
833+
834+-- | /Note:/ this instance works correctly for alphanumeric name and value
835+--
836+-- >>> parseUrlPiece "SESSID=r2t5uvjq435r4q7ib3vtdjq120" :: Either Text SetCookie
837+-- Right (SetCookie {setCookieName = "SESSID", setCookieValue = "r2t5uvjq435r4q7ib3vtdjq120", setCookiePath = Nothing, setCookieExpires = Nothing, setCookieMaxAge = Nothing, setCookieDomain = Nothing, setCookieHttpOnly = False, setCookieSecure = False, setCookieSameSite = Nothing})
838+instance FromHttpApiData SetCookie where
839+ parseUrlPiece = parseHeader . encodeUtf8
840+ parseHeader = Right . parseSetCookie
841+
842+-- | Note: this instance is not polykinded
843+instance FromHttpApiData a => FromHttpApiData (Tagged (b :: Type) a) where
844+ parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
845+ parseHeader = coerce (parseHeader :: ByteString -> Either Text a)
846+ parseQueryParam = coerce (parseQueryParam :: Text -> Either Text a)
847+
848+-- | @since 0.4.2
849+instance FromHttpApiData a => FromHttpApiData (Const a b) where
850+ parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
851+ parseHeader = coerce (parseHeader :: ByteString -> Either Text a)
852+ parseQueryParam = coerce (parseQueryParam :: Text -> Either Text a)
853+
854+-- | @since 0.4.2
855+instance FromHttpApiData a => FromHttpApiData (Identity a) where
856+ parseUrlPiece = coerce (parseUrlPiece :: Text -> Either Text a)
857+ parseHeader = coerce (parseHeader :: ByteString -> Either Text a)
858+ parseQueryParam = coerce (parseQueryParam :: Text -> Either Text a)
859+
860+-------------------------------------------------------------------------------
861+-- Attoparsec helpers
862+-------------------------------------------------------------------------------
863+
864+runAtto :: Atto.Parser a -> Text -> Either Text a
865+runAtto p t = case Atto.parseOnly (p <* Atto.endOfInput) t of
866+ Left err -> Left (T.pack err)
867+ Right x -> Right x
stack.yamlnew file+6
@@ -0,0 +1,6 @@
1+resolver: lts-12.10
2+packages:
3+- '.'
4+flags:
5+ http-api-data:
6+ use-text-show: false
test/DocTest.hsdeleted−7
@@ -1,7 +0,0 @@
1−module Main (main) where
2−
3−import System.FilePath.Glob (glob)
4−import Test.DocTest (doctest)
5−
6−main :: IO ()
7−main = glob "Web/**/*.hs" >>= doctest
test/Spec.hs+1−105
@@ -1,105 +1 @@
1−{-# Language ScopedTypeVariables #-}
2−{-# OPTIONS_GHC -fno-warn-orphans #-}
1+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
3−module Main where
4−
5−import Control.Applicative
6−
7−import Data.Int
8−import Data.Char
9−import Data.Word
10−import Data.Time
11−import qualified Data.Text as T
12−import qualified Data.Text.Lazy as L
13−import Data.Version
14−
15−import Test.Hspec
16−import Test.Hspec.QuickCheck(prop)
17−import Test.QuickCheck
18−
19−import Web.HttpApiData
20−
21−instance Arbitrary T.Text where
22− arbitrary = T.pack <$> arbitrary
23−
24−instance Arbitrary L.Text where
25− arbitrary = L.pack <$> arbitrary
26−
27−instance Arbitrary Day where
28− arbitrary = liftA3 fromGregorian (fmap abs arbitrary) arbitrary arbitrary
29−
30−instance Arbitrary Version where
31− arbitrary = (version . map abs) <$> nonempty
32− where
33− version branch = Version branch []
34− nonempty = liftA2 (:) arbitrary arbitrary
35−
36−main :: IO ()
37−main = hspec spec
38−
39−(<=>) :: Eq a => (a -> b) -> (b -> Either T.Text a) -> a -> Bool
40−(f <=> g) x = g (f x) == Right x
41−
42−data Proxy a = Proxy
43−
44−checkUrlPiece :: forall a. (Eq a, ToHttpApiData a, FromHttpApiData a, Show a, Arbitrary a) => Proxy a -> String -> Spec
45−checkUrlPiece _ name = prop name (toUrlPiece <=> parseUrlPiece :: a -> Bool)
46−
47−data RandomCase a = RandomCase [Bool] a
48−
49−instance ToHttpApiData a => Show (RandomCase a) where
50− show rc@(RandomCase _ x) = show (toUrlPiece rc) ++ " (original: " ++ show (toUrlPiece x) ++ ")"
51−
52−instance Eq a => Eq (RandomCase a) where
53− RandomCase _ x == RandomCase _ y = x == y
54−
55−instance Arbitrary a => Arbitrary (RandomCase a) where
56− arbitrary = liftA2 RandomCase nonempty arbitrary
57− where
58− nonempty = liftA2 (:) arbitrary arbitrary
59−
60−instance ToHttpApiData a => ToHttpApiData (RandomCase a) where
61− toUrlPiece (RandomCase us x) = T.pack (zipWith (\u -> if u then toUpper else toLower) (cycle us) (T.unpack (toUrlPiece x)))
62−
63−instance FromHttpApiData a => FromHttpApiData (RandomCase a) where
64− parseUrlPiece s = RandomCase [] <$> parseUrlPiece s
65−
66−-- | Check case insensitivity for @parseUrlPiece@.
67−checkUrlPieceI :: forall a. (Eq a, ToHttpApiData a, FromHttpApiData a, Show a, Arbitrary a) => Proxy a -> String -> Spec
68−checkUrlPieceI _ = checkUrlPiece (Proxy :: Proxy (RandomCase a))
69−
70−spec :: Spec
71−spec = do
72− describe "toUrlPiece <=> parseUrlPiece" $ do
73− checkUrlPiece (Proxy :: Proxy ()) "()"
74− checkUrlPiece (Proxy :: Proxy Char) "Char"
75− checkUrlPieceI (Proxy :: Proxy Bool) "Bool"
76− checkUrlPieceI (Proxy :: Proxy Ordering) "Ordering"
77− checkUrlPiece (Proxy :: Proxy Int) "Int"
78− checkUrlPiece (Proxy :: Proxy Int8) "Int8"
79− checkUrlPiece (Proxy :: Proxy Int16) "Int16"
80− checkUrlPiece (Proxy :: Proxy Int32) "Int32"
81− checkUrlPiece (Proxy :: Proxy Int64) "Int64"
82− checkUrlPiece (Proxy :: Proxy Integer) "Integer"
83− checkUrlPiece (Proxy :: Proxy Word) "Word"
84− checkUrlPiece (Proxy :: Proxy Word8) "Word8"
85− checkUrlPiece (Proxy :: Proxy Word16) "Word16"
86− checkUrlPiece (Proxy :: Proxy Word32) "Word32"
87− checkUrlPiece (Proxy :: Proxy Word64) "Word64"
88− checkUrlPiece (Proxy :: Proxy String) "String"
89− checkUrlPiece (Proxy :: Proxy T.Text) "Text.Strict"
90− checkUrlPiece (Proxy :: Proxy L.Text) "Text.Lazy"
91− checkUrlPiece (Proxy :: Proxy Day) "Day"
92− checkUrlPiece (Proxy :: Proxy Version) "Version"
93−
94− checkUrlPiece (Proxy :: Proxy (Maybe String)) "Maybe String"
95− checkUrlPieceI (Proxy :: Proxy (Maybe Integer)) "Maybe Integer"
96− checkUrlPiece (Proxy :: Proxy (Either Integer T.Text)) "Either Integer Text"
97− checkUrlPieceI (Proxy :: Proxy (Either Version Day)) "Either Version Day"
98−
99− it "bad integers are rejected" $ do
100− parseUrlPieceMaybe (T.pack "123hello") `shouldBe` (Nothing :: Maybe Int)
101−
102− it "bounds checking works" $ do
103− parseUrlPieceMaybe (T.pack "256") `shouldBe` (Nothing :: Maybe Int8)
104− parseUrlPieceMaybe (T.pack "-10") `shouldBe` (Nothing :: Maybe Word)
105−
test/Web/Internal/FormUrlEncodedSpec.hsnew file+69
@@ -0,0 +1,69 @@
1+{-# LANGUAGE CPP #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE ScopedTypeVariables #-}
4+module Web.Internal.FormUrlEncodedSpec (spec) where
5+
6+#if __GLASGOW_HASKELL__ < 710
7+import Control.Applicative
8+import Data.Monoid
9+#endif
10+
11+import Control.Monad ((<=<))
12+import qualified Data.ByteString.Lazy.Char8 as BSL
13+import qualified Data.HashMap.Strict as HashMap
14+import Data.Text (Text, unpack)
15+import Test.Hspec
16+import Test.QuickCheck
17+
18+import GHC.Exts (fromList)
19+
20+import Web.Internal.FormUrlEncoded
21+import Web.Internal.HttpApiData
22+import Web.Internal.TestInstances
23+
24+spec :: Spec
25+spec = do
26+ genericSpec
27+ urlEncoding
28+
29+genericSpec :: Spec
30+genericSpec = describe "Default (generic) instances" $ do
31+
32+ context "ToForm" $ do
33+
34+ it "contains the record names" $ property $ \(x :: SimpleRec) -> do
35+ let f = unForm $ toForm x
36+ HashMap.member "rec1" f `shouldBe` True
37+ HashMap.member "rec2" f `shouldBe` True
38+
39+ it "contains the correct record values" $ property $ \(x :: SimpleRec) -> do
40+ let f = unForm $ toForm x
41+ HashMap.lookup "rec1" f `shouldBe` Just [rec1 x]
42+ (parseQueryParams <$> HashMap.lookup "rec2" f) `shouldBe` Just (Right [rec2 x])
43+
44+ context "FromForm" $ do
45+
46+ it "is the right inverse of ToForm" $ property $ \x (y :: Int) -> do
47+ let f1 = fromList [("rec1", x), ("rec2", toQueryParam y)]
48+ Right r1 = fromForm f1 :: Either Text SimpleRec
49+ toForm r1 `shouldBe` f1
50+
51+ it "returns the underlying error" $ do
52+ let f = fromList [("rec1", "anything"), ("rec2", "bad")]
53+ Left e = fromForm f :: Either Text SimpleRec
54+ unpack e `shouldContain` "input does not start with a digit"
55+
56+urlEncoding :: Spec
57+urlEncoding = describe "urlEncoding" $ do
58+
59+ it "urlDecodeForm (urlEncodeForm x) == Right x" $ property $ \(NoEmptyKeyForm x) -> do
60+ urlDecodeForm (urlEncodeForm x) `shouldBe` Right x
61+
62+ it "urlDecodeAsForm == (fromForm <=< urlDecodeForm)" $ property $ \(x :: BSL.ByteString) -> do
63+ (urlDecodeAsForm x :: Either Text Form) `shouldBe` (fromForm <=< urlDecodeForm) x
64+
65+ it "urlEncodeAsForm == urlEncodeForm . toForm" $ property $ \(x :: Form) -> do
66+ urlEncodeAsForm x `shouldBe` (urlEncodeForm . toForm) x
67+
68+ it "urlDecodeForm \"\" == Right mempty" $ do
69+ urlDecodeForm "" `shouldBe` Right mempty
test/Web/Internal/HttpApiDataSpec.hsnew file+129
@@ -0,0 +1,129 @@
1+{-# LANGUAGE ScopedTypeVariables #-}
2+module Web.Internal.HttpApiDataSpec (spec) where
3+
4+
5+import Prelude ()
6+import Prelude.Compat
7+
8+import qualified Data.ByteString as BS
9+import Data.ByteString.Builder (toLazyByteString)
10+import Data.Char
11+import qualified Data.Fixed as F
12+import Data.Int
13+import qualified Data.Text as T
14+import qualified Data.Text.Encoding as TE
15+import qualified Data.Text.Lazy as L
16+import Data.Time.Compat
17+import Data.Time.Calendar.Month.Compat (Month)
18+import Data.Time.Calendar.Quarter.Compat (Quarter, QuarterOfYear (..))
19+import qualified Data.UUID.Types as UUID
20+import Data.Version
21+import Data.Word
22+import Web.Cookie (SetCookie, defaultSetCookie,
23+ setCookieName, setCookieValue)
24+
25+import Data.Proxy
26+
27+import Numeric.Natural
28+
29+import Test.Hspec
30+import Test.Hspec.QuickCheck (prop)
31+import Test.QuickCheck
32+
33+import Web.Internal.HttpApiData
34+
35+import Web.Internal.TestInstances
36+
37+(<=>) :: forall a b. (Show a, Show b, Eq a) => (a -> b) -> (b -> Either T.Text a) -> a -> Property
38+(f <=> g) x = counterexample
39+ (show lhs' ++ " : " ++ show lhs ++ " /= " ++ show rhs)
40+ (lhs == rhs)
41+ where
42+ lhs' = f x
43+ lhs = g lhs' :: Either T.Text a
44+ rhs = Right x :: Either T.Text a
45+
46+encodedUrlPieceProp :: ToHttpApiData a => a -> Property
47+encodedUrlPieceProp x = toLazyByteString (toEncodedUrlPiece (toUrlPiece x)) === toLazyByteString (toEncodedUrlPiece x)
48+
49+-- | Check 'ToHttpApiData' and 'FromHttpApiData' compatibility
50+checkUrlPiece :: forall a. (Eq a, ToHttpApiData a, FromHttpApiData a, Show a, Arbitrary a) => Proxy a -> String -> Spec
51+checkUrlPiece _ = checkUrlPiece' (arbitrary :: Gen a)
52+
53+checkUrlPiece' :: forall a. (Eq a, ToHttpApiData a, FromHttpApiData a, Show a) => Gen a -> String -> Spec
54+checkUrlPiece' gen name = describe name $ do
55+ prop "toUrlPiece <=> parseUrlPiece" $ forAll gen (toUrlPiece <=> parseUrlPiece :: a -> Property)
56+ prop "toQueryParam <=> parseQueryParam" $ forAll gen (toQueryParam <=> parseQueryParam :: a -> Property)
57+ prop "toHeader <=> parseHeader" $ forAll gen (toHeader <=> parseHeader :: a -> Property)
58+ prop "toEncodedUrlPiece encodes correctly" $ forAll gen encodedUrlPieceProp
59+
60+-- | Check case insensitivity for @parseUrlPiece@.
61+checkUrlPieceI :: forall a. (Eq a, ToHttpApiData a, FromHttpApiData a, Arbitrary a) => Proxy a -> String -> Spec
62+checkUrlPieceI _ = checkUrlPiece (Proxy :: Proxy (RandomCase a))
63+
64+spec :: Spec
65+spec = do
66+ describe "Instances" $ do
67+ checkUrlPiece (Proxy :: Proxy ()) "()"
68+ checkUrlPiece (Proxy :: Proxy Char) "Char"
69+ checkUrlPieceI (Proxy :: Proxy Bool) "Bool"
70+ checkUrlPieceI (Proxy :: Proxy Ordering) "Ordering"
71+ checkUrlPiece (Proxy :: Proxy Int) "Int"
72+ checkUrlPiece (Proxy :: Proxy Int8) "Int8"
73+ checkUrlPiece (Proxy :: Proxy Int16) "Int16"
74+ checkUrlPiece (Proxy :: Proxy Int32) "Int32"
75+ checkUrlPiece (Proxy :: Proxy Int64) "Int64"
76+ checkUrlPiece (Proxy :: Proxy Integer) "Integer"
77+ checkUrlPiece (Proxy :: Proxy Word) "Word"
78+ checkUrlPiece (Proxy :: Proxy Word8) "Word8"
79+ checkUrlPiece (Proxy :: Proxy Word16) "Word16"
80+ checkUrlPiece (Proxy :: Proxy Word32) "Word32"
81+ checkUrlPiece (Proxy :: Proxy Word64) "Word64"
82+ checkUrlPiece (Proxy :: Proxy String) "String"
83+ checkUrlPiece (Proxy :: Proxy T.Text) "Text.Strict"
84+ checkUrlPiece (Proxy :: Proxy L.Text) "Text.Lazy"
85+ checkUrlPiece (Proxy :: Proxy Day) "Day"
86+ checkUrlPiece (Proxy :: Proxy TimeOfDay) "TimeOfDay"
87+ checkUrlPiece (Proxy :: Proxy LocalTime) "LocalTime"
88+ checkUrlPiece (Proxy :: Proxy ZonedTime) "ZonedTime"
89+ checkUrlPiece (Proxy :: Proxy UTCTime) "UTCTime"
90+ checkUrlPiece (Proxy :: Proxy NominalDiffTime) "NominalDiffTime"
91+ checkUrlPiece (Proxy :: Proxy DayOfWeek) "DayOfWeek"
92+ checkUrlPiece (Proxy :: Proxy Quarter) "Quarter"
93+ checkUrlPiece (Proxy :: Proxy QuarterOfYear) "QuarterOfYear"
94+ checkUrlPiece (Proxy :: Proxy Month) "Month"
95+ checkUrlPiece (Proxy :: Proxy DayOfWeek) "DayOfWeek"
96+ checkUrlPiece (Proxy :: Proxy Version) "Version"
97+ checkUrlPiece (Proxy :: Proxy UUID.UUID) "UUID"
98+ checkUrlPiece' setCookieGen "Cookie"
99+
100+ checkUrlPiece (Proxy :: Proxy F.Uni) "Uni"
101+ checkUrlPiece (Proxy :: Proxy F.Deci) "Deci"
102+ checkUrlPiece (Proxy :: Proxy F.Centi) "Centi"
103+ checkUrlPiece (Proxy :: Proxy F.Milli) "Milli"
104+ checkUrlPiece (Proxy :: Proxy F.Micro) "Micro"
105+ checkUrlPiece (Proxy :: Proxy F.Nano) "Nano"
106+ checkUrlPiece (Proxy :: Proxy F.Pico) "Pico"
107+
108+ checkUrlPiece (Proxy :: Proxy (Maybe String)) "Maybe String"
109+ checkUrlPieceI (Proxy :: Proxy (Maybe Integer)) "Maybe Integer"
110+ checkUrlPiece (Proxy :: Proxy (Either Integer T.Text)) "Either Integer Text"
111+ checkUrlPieceI (Proxy :: Proxy (Either Version Day)) "Either Version Day"
112+
113+ checkUrlPiece (Proxy :: Proxy Natural) "Natural"
114+
115+ it "bad integers are rejected" $ do
116+ parseUrlPieceMaybe (T.pack "123hello") `shouldBe` (Nothing :: Maybe Int)
117+
118+ it "bounds checking works" $ do
119+ parseUrlPieceMaybe (T.pack "256") `shouldBe` (Nothing :: Maybe Int8)
120+ parseUrlPieceMaybe (T.pack "-10") `shouldBe` (Nothing :: Maybe Word)
121+
122+ it "invalid utf8 is handled" $ do
123+ parseHeaderMaybe (BS.pack [128]) `shouldBe` (Nothing :: Maybe T.Text)
124+
125+setCookieGen :: Gen SetCookie
126+setCookieGen = do
127+ n <- TE.encodeUtf8 . T.pack . filter isAlphaNum <$> arbitrary
128+ v <- TE.encodeUtf8 . T.pack . filter isAlphaNum <$> arbitrary
129+ return $ defaultSetCookie { setCookieName = n, setCookieValue = v }
test/Web/Internal/TestInstances.hsnew file+66
@@ -0,0 +1,66 @@
1+{-# LANGUAGE DeriveGeneric #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE ScopedTypeVariables #-}
4+{-# OPTIONS_GHC -fno-warn-orphans #-}
5+module Web.Internal.TestInstances
6+ ( RandomCase(..)
7+ , SimpleRec(..)
8+ , NoEmptyKeyForm(..)
9+ ) where
10+
11+import Control.Applicative
12+import Data.Char
13+import qualified Data.HashMap.Strict as HashMap
14+import qualified Data.Text as T
15+import Data.Time.Compat
16+import GHC.Exts (fromList)
17+import GHC.Generics
18+
19+import Test.QuickCheck
20+import Test.QuickCheck.Instances ()
21+
22+import Web.Internal.FormUrlEncoded
23+import Web.Internal.HttpApiData
24+
25+instance Eq ZonedTime where
26+ ZonedTime t (TimeZone x _ _) == ZonedTime t' (TimeZone y _ _) = t == t' && x == y
27+
28+instance Arbitrary Form where
29+ arbitrary = fromList <$> arbitrary
30+
31+data RandomCase a = RandomCase [Bool] a
32+
33+instance ToHttpApiData a => Show (RandomCase a) where
34+ show rc@(RandomCase _ x) = show (toUrlPiece rc) ++ " (original: " ++ show (toUrlPiece x) ++ ")"
35+
36+instance Eq a => Eq (RandomCase a) where
37+ RandomCase _ x == RandomCase _ y = x == y
38+
39+instance Arbitrary a => Arbitrary (RandomCase a) where
40+ arbitrary = liftA2 RandomCase nonempty arbitrary
41+ where
42+ nonempty = liftA2 (:) arbitrary arbitrary
43+
44+instance ToHttpApiData a => ToHttpApiData (RandomCase a) where
45+ toUrlPiece (RandomCase us x) = T.pack (zipWith (\u -> if u then toUpper else toLower) (cycle us) (T.unpack (toUrlPiece x)))
46+
47+instance FromHttpApiData a => FromHttpApiData (RandomCase a) where
48+ parseUrlPiece s = RandomCase [] <$> parseUrlPiece s
49+
50+data SimpleRec = SimpleRec { rec1 :: T.Text, rec2 :: Int }
51+ deriving (Eq, Show, Read, Generic)
52+
53+instance ToForm SimpleRec
54+instance FromForm SimpleRec
55+
56+instance Arbitrary SimpleRec where
57+ arbitrary = SimpleRec <$> arbitrary <*> arbitrary
58+
59+newtype NoEmptyKeyForm =
60+ NoEmptyKeyForm { unNoEmptyKeyForm :: Form }
61+ deriving Show
62+
63+instance Arbitrary NoEmptyKeyForm where
64+ arbitrary = NoEmptyKeyForm . removeEmptyKeys <$> arbitrary
65+ where
66+ removeEmptyKeys (Form m) = Form (HashMap.delete "" m)