diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2016-05-27 19:40:18 +0200 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2016-05-27 19:40:18 +0200 |
commit | 5b063193f389ef472366e4355a683f1843f29733 (patch) | |
tree | 73f1f8cf6d3834983cb9233ba6cc5eea5907f324 /provider/posts/thermoprint | |
parent | b884925f12aae6967752e85457d2fc8abd9bffe0 (diff) | |
download | dirty-haskell.org-5b063193f389ef472366e4355a683f1843f29733.tar dirty-haskell.org-5b063193f389ef472366e4355a683f1843f29733.tar.gz dirty-haskell.org-5b063193f389ef472366e4355a683f1843f29733.tar.bz2 dirty-haskell.org-5b063193f389ef472366e4355a683f1843f29733.tar.xz dirty-haskell.org-5b063193f389ef472366e4355a683f1843f29733.zip |
structure
Diffstat (limited to 'provider/posts/thermoprint')
-rw-r--r-- | provider/posts/thermoprint/1.md | 130 | ||||
-rw-r--r-- | provider/posts/thermoprint/2.lhs | 262 | ||||
-rw-r--r-- | provider/posts/thermoprint/3.lhs | 92 | ||||
-rw-r--r-- | provider/posts/thermoprint/4.md | 116 | ||||
-rw-r--r-- | provider/posts/thermoprint/5.md | 198 | ||||
-rw-r--r-- | provider/posts/thermoprint/6.lhs | 142 |
6 files changed, 940 insertions, 0 deletions
diff --git a/provider/posts/thermoprint/1.md b/provider/posts/thermoprint/1.md new file mode 100644 index 0000000..032e2f6 --- /dev/null +++ b/provider/posts/thermoprint/1.md | |||
@@ -0,0 +1,130 @@ | |||
1 | --- | ||
2 | title: On the Architecture of a tool-set for interacting with character-oriented printers | ||
3 | published: 2015-12-25 | ||
4 | tags: Thermoprint | ||
5 | --- | ||
6 | |||
7 | # Motivation | ||
8 | |||
9 | Some time ago I bought a cheap Chinese | ||
10 | [thermoprinter](https://en.wikipedia.org/wiki/Thermal_printing) off eBay. | ||
11 | As expected the printers firmware is really awkward to use (including binary | ||
12 | control codes used to switch between char sets such as bold, italic, underlined, | ||
13 | etc.). | ||
14 | The obvious solution was to write a library to parse a more sensible | ||
15 | representation and send it to be printed. | ||
16 | |||
17 | Since there might, at some point, be other users wanting to print to my | ||
18 | acquisition the architecture is intended to be present a somewhat usable | ||
19 | interface to the uninitiated. | ||
20 | |||
21 | # Implementation | ||
22 | |||
23 | ## Location | ||
24 | |||
25 | Recently I created a new branch in | ||
26 | [thermoprint](https://git.yggdrasil.li/thermoprint) called | ||
27 | [rewrite](https://git.yggdrasil.li/thermoprint?h=rewrite). | ||
28 | |||
29 | ## Architecture Overview | ||
30 | |||
31 | The new macroscopic architecture I´m currently aiming for is quite similar to | ||
32 | the old one: | ||
33 | |||
34 | * A server intended to run on the machine connected to my cheap printer talking | ||
35 | directly to the printer on one end and serving a | ||
36 | [json api](https://hackage.haskell.org/package/servant) on the other. | ||
37 | * A (hopefully) tiny cli tool for debugging and personal use. | ||
38 | * A website (it will probably end up being based on | ||
39 | [yesod](https://hackage.haskell.org/package/yesod)) presenting a web interface | ||
40 | similar to the cli tool. | ||
41 | |||
42 | ## Features | ||
43 | |||
44 | Features I intend to implement include: | ||
45 | |||
46 | * A parser for a bbcode-dialect which should be used in both the cli tool and the | ||
47 | website (it will probably end up using | ||
48 | [attoparsec](https://hackage.haskell.org/package/attoparsec)) -- bbcode as | ||
49 | presented on [Wikipedia](https://en.wikipedia.org/wiki/BBCode) is a proper | ||
50 | superset of the feature-set of my cheap Chinese printer. | ||
51 | * Reasonable test coverage using | ||
52 | [QuickCheck](https://hackage.haskell.org/package/QuickCheck), | ||
53 | [HUnit](http://hackage.haskell.org/package/HUnit). | ||
54 | |||
55 | Automatic testing with [cabal](https://www.haskell.org/cabal/) facilitated by | ||
56 | [hspec](https://hackage.haskell.org/package/hspec). | ||
57 | * Support and server-side storage for drafts. | ||
58 | * The Website should provide some richer formats than bbcode which will | ||
59 | probably find inclusion in the payload datastructure such as lists, | ||
60 | checklists, tables, etc. | ||
61 | |||
62 | The cli-tool should be able to use these too (the input will probably end up | ||
63 | being json-formatted). | ||
64 | |||
65 | ## Work so far | ||
66 | |||
67 | ### Prototype | ||
68 | |||
69 | I already have a prototype. | ||
70 | It's quite bug-ridden and has recently developed serious problems actually | ||
71 | printing after working satisfactorily for a few weeks. | ||
72 | |||
73 | It also does not include a web-interface and I am quite unsatisfied with the | ||
74 | overall code quality. | ||
75 | |||
76 | The [685 lines of code](http://cloc.sourceforge.net/) can be found in the | ||
77 | [repo](https://git.yggdrasil.li/thermoprint?h=master) as well. | ||
78 | |||
79 | ### Rewrite | ||
80 | |||
81 | Currently the [rewrite](https://git.yggdrasil.li/thermoprint?h=rewrite) contains a | ||
82 | single file of moment -- spec/src/Thermoprint/Printout.hs -- wherein we define | ||
83 | the payload for the api -- our take on a structured document format (somewhat | ||
84 | inspired by | ||
85 | [pandoc](http://hackage.haskell.org/package/pandoc-types/docs/Text-Pandoc-Definition.html)): | ||
86 | |||
87 | ~~~ {.haskell} | ||
88 | -- | A 'Printout' is a sequence of visually seperated 'Paragraph's | ||
89 | type Printout = Seq Paragraph | ||
90 | |||
91 | -- | A 'Paragraph' is a non-seperated sequence of 'Chunk's | ||
92 | type Paragraph = Seq Chunk | ||
93 | |||
94 | -- | We introduce both 'Chunk' and 'Paragraph' mainly to allow 'Raw'. | ||
95 | -- | ||
96 | -- Were we to disallow 'Raw', 'Block' would be identical to 'Paragraph' | ||
97 | data Chunk = Cooked Block -- ^ text semantically structured to be rendered in accordance with the display format of printer | ||
98 | | Raw ByteString -- ^ direct instructions to the printer | ||
99 | deriving (Generic, NFData, Show, CoArbitrary) | ||
100 | |||
101 | -- | 'Block' is the entry point for our structured document format | ||
102 | data Block = Line Line -- ^ a single 'Line' of text | ||
103 | | VSpace Integer -- ^ vertical space of height equivalent to 'Integer' lines | ||
104 | | NewlSep (Seq Block) -- ^ A sequence of 'Block's seperated by newlines | ||
105 | deriving (Generic, NFData, Show, CoArbitrary) | ||
106 | |||
107 | {- | A 'Line' is one of: | ||
108 | |||
109 | * a single word | ||
110 | * horizontal space equivalent to the width of 'Integer' `em`. | ||
111 | * a sequence of words seperated by spaces | ||
112 | |||
113 | We don't export all constructors and instead encourage the use of 'text'. | ||
114 | -} | ||
115 | data Line = Word Text | ||
116 | | HSpace Integer | ||
117 | | SpaceSep (Seq Line) | ||
118 | deriving (Generic, NFData, Show, CoArbitrary) | ||
119 | ~~~ | ||
120 | |||
121 | (The code is verbatim as of 8307d7e). | ||
122 | |||
123 | <!-- LocalWords: Thermoprint thermoprint json api cli yesod bbcode attoparsec | ||
124 | --> | ||
125 | <!-- LocalWords: superset QuickCheck HUnit hspec datastructure repo pandoc | ||
126 | --> | ||
127 | <!-- LocalWords: haskell ByteString NFData CoArbitrary VSpace | ||
128 | --> | ||
129 | <!-- LocalWords: NewlSep HSpace SpaceSep | ||
130 | --> | ||
diff --git a/provider/posts/thermoprint/2.lhs b/provider/posts/thermoprint/2.lhs new file mode 100644 index 0000000..a144fb5 --- /dev/null +++ b/provider/posts/thermoprint/2.lhs | |||
@@ -0,0 +1,262 @@ | |||
1 | --- | ||
2 | title: On the design of a structured document format compatible with character oriented printers | ||
3 | published: 2016-01-11 | ||
4 | tags: Thermoprint | ||
5 | --- | ||
6 | |||
7 | This post is an annotated version of the file [spec/src/Thermoprint/Printout.hs](https://git.yggdrasil.li/thermoprint/tree/spec/src/Thermoprint/Printout.hs?h=rewrite&id=f6dc3d1) as of commit `f6dc3d1`. | ||
8 | |||
9 | > {-# LANGUAGE DeriveGeneric, DeriveAnyClass #-} | ||
10 | > {-# LANGUAGE OverloadedStrings #-} | ||
11 | > {-# OPTIONS_HADDOCK show-extensions #-} | ||
12 | |||
13 | Motivation | ||
14 | ---------- | ||
15 | |||
16 | We want our codebase to be compatible with as many different models of printers as we are willing to implement. | ||
17 | It is therefore desirable to maintain a structured document format which we can transform into a printer-specific representation of the payload to be printed with minimal effort. | ||
18 | |||
19 | In this post we present one such format. | ||
20 | |||
21 | Contents | ||
22 | -------- | ||
23 | |||
24 | > -- | This module contains the definition of the structure -- 'Printout' -- used to represent the content of a job | ||
25 | > module Thermoprint.Printout | ||
26 | > ( Printout(..) | ||
27 | > , Paragraph(..) | ||
28 | > , Chunk(..) | ||
29 | > , Block(..) | ||
30 | > , Line( HSpace | ||
31 | > , SpaceSep | ||
32 | > ) | ||
33 | > , text, cotext | ||
34 | > , prop_text | ||
35 | > ) where | ||
36 | |||
37 | Preliminaries | ||
38 | ------------- | ||
39 | |||
40 | > import Data.Sequence (Seq, (|>), (<|)) | ||
41 | |||
42 | A Sequence represents the same structure as the linked lists common in haskell but supports $O(1)$ `snoc`, which is desirable since we intend to iteratively build up the structure when parsing input formats. | ||
43 | |||
44 | > import Data.Text.Lazy (Text) | ||
45 | > | ||
46 | > import Data.ByteString.Lazy (ByteString) | ||
47 | |||
48 | The entire structure will be lazy by default but an instance of `NFData`, thus the lazy variants of `Text` and `ByteString`. | ||
49 | |||
50 | > import GHC.Generics (Generic) | ||
51 | |||
52 | We will use derived instances of `Generic` to get handed suitable instances of rather complicated classes such as `Arbitrary` and `FromJSON` | ||
53 | |||
54 | > import Control.DeepSeq (NFData) | ||
55 | |||
56 | Instances of `NFData` allow us to strictly evaluate our document structure when needed | ||
57 | |||
58 | > import Data.Aeson (FromJSON(..), ToJSON(..), Value(..)) | ||
59 | > import qualified Data.Aeson as JSON (encode, decode) | ||
60 | > import Data.Aeson.Types (typeMismatch) | ||
61 | |||
62 | We will encode the document as a [json](https://en.wikipedia.org/wiki/JSON) object during transport | ||
63 | |||
64 | > import Test.QuickCheck.Arbitrary (Arbitrary(..), CoArbitrary, genericShrink) | ||
65 | > import Test.QuickCheck.Modifiers (NonNegative(..)) | ||
66 | > import Test.QuickCheck.Gen (oneof, suchThat, scale) | ||
67 | > import Test.QuickCheck.Instances | ||
68 | > import Test.QuickCheck (forAll, Property) | ||
69 | |||
70 | We will use [QuickCheck](https://hackage.haskell.org/package/QuickCheck) for automatic test generation. | ||
71 | |||
72 | > import qualified Data.Text.Lazy as TL (split, null, pack, filter, intercalate, map) | ||
73 | > import qualified Data.Text as T (pack) | ||
74 | > import Data.Char (isSpace) | ||
75 | > | ||
76 | > import Data.Monoid (Monoid(..), (<>)) | ||
77 | > | ||
78 | > import Data.List (dropWhile, dropWhileEnd, groupBy, genericLength, genericReplicate) | ||
79 | > | ||
80 | > import Data.Sequence as Seq (fromList, null, singleton) | ||
81 | > | ||
82 | > import Data.Function (on) | ||
83 | > | ||
84 | > import Data.Foldable (toList, fold) | ||
85 | |||
86 | We will need to do some parsing and pretty-printing to implement `text` and `cotext`, respectively. | ||
87 | |||
88 | > import Data.Encoding (encodeLazyByteStringExplicit, decodeLazyByteString) | ||
89 | > import Data.Encoding.UTF8 | ||
90 | > import qualified Data.ByteString.Base64.Lazy as Base64 (encode, decode) | ||
91 | |||
92 | Since we want end users to be able to include direct instructions the printer in the form of a lazy [`ByteString`](https://hackage.haskell.org/package/bytestring) we need some way to encode `ByteString`s in JSON. | ||
93 | We chose [base64](https://hackage.haskell.org/package/base64-bytestring). | ||
94 | |||
95 | > import Prelude hiding (fold) | ||
96 | > | ||
97 | > | ||
98 | > -- | A 'Printout' is a sequence of visually seperated 'Paragraph's | ||
99 | > type Printout = Seq Paragraph | ||
100 | |||
101 | "visually seperated" will most likely end up meaning "seperated by a single blank line" | ||
102 | |||
103 | > -- | A 'Paragraph' is a non-seperated sequence of 'Chunk's | ||
104 | > type Paragraph = Seq Chunk | ||
105 | > | ||
106 | > -- | We introduce both 'Chunk' and 'Paragraph' mainly to allow 'Raw'. | ||
107 | > -- | ||
108 | > -- Were we to disallow 'Raw', 'Block' would be identical to 'Paragraph' | ||
109 | > data Chunk = Cooked Block -- ^ text semantically structured to be rendered in accordance with the display format of printer | ||
110 | > | Raw ByteString -- ^ direct instructions to the printer | ||
111 | > deriving (Generic, NFData, Show, CoArbitrary) | ||
112 | > | ||
113 | > instance FromJSON Chunk where | ||
114 | > parseJSON s@(String _) = Raw <$> ((either fail return . decodeBase64) =<< parseJSON s) | ||
115 | > where | ||
116 | > decodeBase64 :: String -> Either String ByteString | ||
117 | > decodeBase64 s = (either (Left . show) Right . encodeLazyByteStringExplicit UTF8Strict $ s) >>= Base64.decode | ||
118 | > parseJSON o@(Object _) = Cooked <$> parseJSON o | ||
119 | > parseJSON v = typeMismatch "Chunk" v | ||
120 | > | ||
121 | > instance ToJSON Chunk where | ||
122 | > toJSON (Raw bs) = String . T.pack . decodeLazyByteString UTF8Strict . Base64.encode $ bs | ||
123 | > toJSON (Cooked block) = toJSON block | ||
124 | |||
125 | We provide custom instances of `FromJSON Chunk` and `ToJSON Chunk` so that we might reduce the sice of the resulting JSON somewhat (this is an opportune target since disambiguaty is simple) | ||
126 | |||
127 | > -- | 'Block' is the entry point for our structured document format | ||
128 | > data Block = Line Line -- ^ a single 'Line' of text | ||
129 | > | VSpace Integer -- ^ vertical space of height equivalent to 'Integer' lines | ||
130 | > | NewlSep (Seq Block) -- ^ A sequence of 'Block's seperated by newlines | ||
131 | > deriving (Generic, NFData, Show, CoArbitrary, FromJSON, ToJSON) | ||
132 | > | ||
133 | > {- | A 'Line' is one of: | ||
134 | > | ||
135 | > * a single word | ||
136 | > * horizontal space equivalent to the width of 'Integer' `em`. | ||
137 | > * a sequence of words seperated by spaces | ||
138 | > | ||
139 | > We don't export all constructors and instead encourage the use of 'text'. | ||
140 | > -} | ||
141 | > data Line = Word Text | ||
142 | > | HSpace Integer | ||
143 | > | SpaceSep (Seq Line) | ||
144 | > deriving (Generic, NFData, Show, CoArbitrary, FromJSON, ToJSON) | ||
145 | > | ||
146 | > instance Monoid Block where | ||
147 | > mempty = NewlSep mempty | ||
148 | > x@(NewlSep xs) `mappend` y@(NewlSep ys) | ||
149 | > | Seq.null xs = y | ||
150 | > | Seq.null ys = x | ||
151 | > | otherwise = NewlSep (xs <> ys) | ||
152 | > (NewlSep xs) `mappend` y | ||
153 | > | Seq.null xs = y | ||
154 | > | otherwise = NewlSep (xs |> y) | ||
155 | > x `mappend` (NewlSep ys) | ||
156 | > | Seq.null ys = x | ||
157 | > | otherwise = NewlSep (x <| ys) | ||
158 | > x `mappend` y = NewlSep $ Seq.fromList [x, y] | ||
159 | > | ||
160 | > instance Monoid Line where | ||
161 | > mempty = SpaceSep mempty | ||
162 | > x@(SpaceSep xs) `mappend` y@(SpaceSep ys) | ||
163 | > | Seq.null xs = y | ||
164 | > | Seq.null ys = x | ||
165 | > | otherwise = SpaceSep (xs <> ys) | ||
166 | > (SpaceSep xs) `mappend` y | ||
167 | > | Seq.null xs = y | ||
168 | > | otherwise = SpaceSep (xs |> y) | ||
169 | > x `mappend` (SpaceSep ys) | ||
170 | > | Seq.null ys = x | ||
171 | > | otherwise = SpaceSep (x <| ys) | ||
172 | > x `mappend` y = SpaceSep $ Seq.fromList [x, y] | ||
173 | |||
174 | The Monoid instances for `Block` and `Line` are somewhat unwieldy since we want to guarantee minimum overhead by reducing expressions such as `SpaceSep (fromList [x])` to `x` whenever possible. | ||
175 | |||
176 | The same effect would have been possible by introducing the monoid structure *one level higher* -- we could have introduced constructors such as `Line :: Seq Word -> Block`. | ||
177 | This was deemed undesirable since we would not have been able to implement instances such as `Monoid Line` which allow the use of more generic functions during parsing. | ||
178 | |||
179 | > text :: Text -> Either Block Line | ||
180 | > -- ^ Smart constructor for 'Line'/'Block' which maps word and line boundaries (as determined by 'isSpace' and '(== '\n')' respectively) to the structure of 'Block' and 'Line'. | ||
181 | > -- | ||
182 | > -- Since we are unwilling to duplicate the list of chars from 'isSpace' we cannot reasonably determine a width for the various whitespace 'Char's. | ||
183 | > -- Thus they are all weighted equally as having width 1 `em`. | ||
184 | > text t = case splitLines t of | ||
185 | > [] -> Right mempty | ||
186 | > [Line x] -> Right x | ||
187 | > xs -> Left $ mconcat xs | ||
188 | > where | ||
189 | > splitLines :: Text -> [Block] | ||
190 | > splitLines t = map toBlock | ||
191 | > . groupBy ((==) `on` TL.null) | ||
192 | > $ TL.split (== '\n') t | ||
193 | > splitWords :: Text -> [Line] | ||
194 | > splitWords t = map toLine | ||
195 | > . groupBy ((==) `on` TL.null) | ||
196 | > $ TL.split isSpace t | ||
197 | > toBlock [] = mempty | ||
198 | > toBlock xs@(x:_) | ||
199 | > | TL.null x = VSpace $ genericLength xs - 1 | ||
200 | > | otherwise = mconcat . map (Line . mconcat . splitWords) $ xs | ||
201 | > toLine [] = mempty | ||
202 | > toLine xs@(x:_) | ||
203 | > | TL.null x = HSpace $ genericLength xs - 1 | ||
204 | > | otherwise = mconcat . map Word $ xs | ||
205 | > list :: b -> (a -> [a] -> b) -> [a] -> b | ||
206 | > list c _ [] = c | ||
207 | > list _ f (x:xs) = f x xs | ||
208 | |||
209 | Implementations using `TL.lines` and `TL.words` were tested. | ||
210 | We chose to use `TL.split`-based solutions instead because the more specific splitting functions provided by [text](https://hackage.haskell.org/package/text) drop information concerning the exact amount of whitespace. | ||
211 | |||
212 | > cotext :: Block -> Text | ||
213 | > -- ^ inverse of | ||
214 | > -- @ | ||
215 | > -- either id Line . `text` | ||
216 | > -- @ | ||
217 | > cotext (VSpace n) = TL.pack . genericReplicate n $ '\n' | ||
218 | > cotext (NewlSep xs) = TL.intercalate "\n" . map cotext . toList $ xs | ||
219 | > cotext (Line x) = cotext' x | ||
220 | > where | ||
221 | > cotext' (Word x) = x | ||
222 | > cotext' (HSpace n) = TL.pack . genericReplicate n $ ' ' | ||
223 | > cotext' (SpaceSep xs) = TL.intercalate " " . map cotext' . toList $ xs | ||
224 | |||
225 | We provide cotext for testing `text` and to enable determining semantic equality of `Printout`s at a later date | ||
226 | |||
227 | > prop_text :: Text -> Bool | ||
228 | > -- ^ prop> (`cotext` . either id Line . `text` $ x) == x | ||
229 | > -- | ||
230 | > -- Where 'x' is restricted to those `TL.Text` which do not contain whitespace besides ' ' and '\n'. | ||
231 | > prop_text x = (cotext . either id Line . text $ x') == x' | ||
232 | > where | ||
233 | > x' = TL.map normSpace x | ||
234 | > normSpace c | ||
235 | > | isSpace c | ||
236 | > , c `elem` keep = c | ||
237 | > | isSpace c = ' ' -- We have to do this because all whitespace gets interpreted as width 1 | ||
238 | > | otherwise = c | ||
239 | > keep = [' ', '\n'] | ||
240 | > | ||
241 | > -- | We don't test 'Raw' 'Chunk's | ||
242 | > instance Arbitrary Chunk where | ||
243 | > shrink = genericShrink | ||
244 | > arbitrary = Cooked <$> arbitrary | ||
245 | > | ||
246 | > instance Arbitrary Block where | ||
247 | > shrink = genericShrink | ||
248 | > arbitrary = oneof [ Line <$> arbitrary | ||
249 | > , VSpace . getNonNegative <$> arbitrary | ||
250 | > , NewlSep <$> scale' arbitrary | ||
251 | > ] | ||
252 | > | ||
253 | > instance Arbitrary Line where | ||
254 | > shrink = genericShrink | ||
255 | > arbitrary = oneof [ Word . TL.filter (not . isSpace) <$> arbitrary -- 'isSpace '\n' == True' | ||
256 | > , HSpace . getNonNegative <$> arbitrary | ||
257 | > , SpaceSep <$> scale' arbitrary | ||
258 | > ] | ||
259 | > | ||
260 | > scale' = scale (round . sqrt . fromInteger . toInteger) | ||
261 | |||
262 | Failing to properly scale the tested structures was shown to use more than 8GiB of RAM during testing | ||
diff --git a/provider/posts/thermoprint/3.lhs b/provider/posts/thermoprint/3.lhs new file mode 100644 index 0000000..5e7eee9 --- /dev/null +++ b/provider/posts/thermoprint/3.lhs | |||
@@ -0,0 +1,92 @@ | |||
1 | --- | ||
2 | title: Thoughts on a network protocol for a toolset for interacting with character-oriented printers | ||
3 | published: 2016-01-11 | ||
4 | tags: Thermoprint | ||
5 | --- | ||
6 | |||
7 | This post is an annotated version of the file [spec/src/Thermoprint/API.hs](https://git.yggdrasil.li/thermoprint/tree/spec/src/Thermoprint/API.hs?h=rewrite&id=3ad700c) as of commit `3ad700c`. | ||
8 | |||
9 | > {-# LANGUAGE DeriveGeneric, DeriveAnyClass #-} | ||
10 | > {-# LANGUAGE TypeOperators, DataKinds #-} | ||
11 | > {-# LANGUAGE OverloadedStrings #-} | ||
12 | > | ||
13 | > module Thermoprint.API | ||
14 | > ( PrinterStatus(..) | ||
15 | > , JobStatus(..) | ||
16 | > , ThermoprintAPI | ||
17 | > , thermoprintAPI | ||
18 | > , module Thermoprint.Identifiers | ||
19 | > , module Thermoprint.Printout | ||
20 | > ) where | ||
21 | > | ||
22 | > import Thermoprint.Printout | ||
23 | |||
24 | See [a previous post](https://dirty-haskell.org/posts/thermoprint-2.html). | ||
25 | |||
26 | > import Thermoprint.Identifiers | ||
27 | |||
28 | `Thermoprint.Identifiers` provides some newtypes of `Integer` to add some typesafety to dealing with objects identified by autoincremented numbers | ||
29 | |||
30 | > import Servant.API | ||
31 | > import Servant.Docs | ||
32 | > import Data.Aeson | ||
33 | |||
34 | We will define our API to be compatible with [servant](https://hackage.haskell.org/package/servant) | ||
35 | |||
36 | > import Data.Set (Set) | ||
37 | > import Data.Sequence (Seq) | ||
38 | |||
39 | Higher performance versions of lists for our various applications | ||
40 | |||
41 | > import GHC.Generics (Generic) | ||
42 | > | ||
43 | > import Data.Proxy (Proxy(..)) | ||
44 | > | ||
45 | > import Control.Exception (Exception) | ||
46 | > import Data.Typeable (Typeable) | ||
47 | > | ||
48 | > data PrinterStatus = Busy JobId | ||
49 | > | Available | ||
50 | > deriving (Generic, Show, FromJSON, ToJSON) | ||
51 | > | ||
52 | > data JobStatus = Queued | ||
53 | > | Printing | ||
54 | > | Done | ||
55 | > | Failed PrintingError | ||
56 | > deriving (Generic, Show, FromJSON, ToJSON) | ||
57 | > | ||
58 | > data PrintingError = UnknownError | ||
59 | > deriving (Typeable, Generic, Show, FromJSON, ToJSON, Exception) | ||
60 | |||
61 | We expect the definiton of `PrintingError` to grow considerably while implementing a server for this API | ||
62 | |||
63 | We support the following actions through our API: | ||
64 | |||
65 | > type ThermoprintAPI = "printers" :> Get '[JSON] (Set PrinterId) -- List the identifiers of all available printers (/printers) | ||
66 | > :<|> "printer" :> Capture "printerId" PrinterId :> ( | ||
67 | > ReqBody '[JSON] Printout :> Post '[JSON] JobId -- Add a new job to the bottom of the queue by sending its content (/printer:printerId) | ||
68 | > :<|> "status" :> Get '[JSON] PrinterStatus -- Query the current status of a printer (/printer:printerId/status) | ||
69 | > ) | ||
70 | > :<|> "jobs" :> ( | ||
71 | > QueryParam "printer" PrinterId :> QueryParam "min" JobId :> QueryParam "max" JobId :> Get '[JSON] (Seq JobId) -- List all jobs allowing for selection by printerId and pagination (/jobs?printer=*&min=*&max=*) | ||
72 | > ) | ||
73 | > :<|> "job" :> Capture "jobId" JobId :> ( | ||
74 | > Get '[JSON] Printout -- Get the contents of a job currently known to the server (/job:jobId) | ||
75 | > :<|> "status" :> Get '[JSON] JobStatus -- Get the status of a job (/job:jobId/status) | ||
76 | > :<|> "printer" :> Get '[JSON] PrinterId -- Find the printer a job was queued for (/job:jobId/printer) | ||
77 | > :<|> Delete '[] () -- Abort a job (which we expect to make it unknown to the server) (/job:jobId) | ||
78 | > ) | ||
79 | > :<|> "drafts" :> ( | ||
80 | > Get '[JSON] (Set DraftId) -- List the identifiers of all drafts known to the server (/drafts) | ||
81 | > :<|> ReqBody '[JSON] Printout :> Post '[JSON] DraftId -- Make a draft known to the server by submitting its contents (/drafts) | ||
82 | > ) | ||
83 | > :<|> "draft" :> Capture "draftId" DraftId :> ( | ||
84 | > ReqBody '[JSON] Printout :> Put '[] () -- Update a draft by replacing its contents (/draft:draftId) | ||
85 | > :<|> Get '[JSON] Printout -- Get the contents of a draft (/draft:draftId) | ||
86 | > :<|> Delete '[] () -- Delete a draft (/draft:draftId) | ||
87 | > ) | ||
88 | > | ||
89 | > thermoprintAPI :: Proxy ThermoprintAPI | ||
90 | > thermoprintAPI = Proxy | ||
91 | |||
92 | servant needs an object of type `Proxy ThermoprintAPI` in various places | ||
diff --git a/provider/posts/thermoprint/4.md b/provider/posts/thermoprint/4.md new file mode 100644 index 0000000..756c166 --- /dev/null +++ b/provider/posts/thermoprint/4.md | |||
@@ -0,0 +1,116 @@ | |||
1 | --- | ||
2 | title: On the Design of a Parser | ||
3 | published: 2016-01-12 | ||
4 | tags: Thermoprint | ||
5 | --- | ||
6 | |||
7 | The concrete application we’ll be walking through is a naive parser for [bbcode](https://en.wikipedia.org/wiki/BBCode) | ||
8 | -- more specifically the contents of the directory `bbcode` in the | ||
9 | [git repo](https://git.yggdrasil.li/thermoprint/tree/bbcode?h=rewrite&id=dc99dae). | ||
10 | |||
11 | In a manner consistent with designing software as | ||
12 | [compositions of simple morphisms](https://en.wikipedia.org/wiki/Tacit_programming) we start by determining the type of | ||
13 | our solution (as illustrated by the following mockup): | ||
14 | |||
15 | ~~~ {.haskell} | ||
16 | -- | Our target structure -- a rose tree with an explicit terminal constructor | ||
17 | data DomTree = Element Text (Map Text Text) [DomTree] | ||
18 | | Content Text | ||
19 | deriving (Show, Eq) | ||
20 | |||
21 | bbcode :: Text -> Maybe DomTree | ||
22 | -- ^ Parse BBCode | ||
23 | ~~~ | ||
24 | |||
25 | Writing a parser capable of dealing with `Text` directly from scratch would be unnecessarily abstruse, we’ll be using | ||
26 | the [attoparsec](https://hackage.haskell.org/package/attoparsec/docs/Data-Attoparsec-Text.html) family of parser | ||
27 | combinators instead. | ||
28 | |||
29 | We reproduce an incomplete version of the lexer below (it’s missing tag attributes and self-closing tags). | ||
30 | |||
31 | We introduce `escapedText`, a helper function for extracting text until we reach one of a set of delimiting characters | ||
32 | (exclusive). | ||
33 | While doing this we also parse any delimiting character iff it's prefixed with an escape character (we use `\`) -- the | ||
34 | escape character itself needs only be escaped if encountered directly before one of the delimiting characters. | ||
35 | |||
36 | ~~~ {.haskell} | ||
37 | data Token = BBOpen Text -- ^ "[open]" | ||
38 | | BBClose Text -- ^ "[/close]" | ||
39 | | BBStr Text -- ^ "text" | ||
40 | |||
41 | token :: Parser [Token] | ||
42 | token = BBClose <$ "[/" <*> escapedText' [']'] <* "]" | ||
43 | <|> BBOpen <$ "[" <*> escapedText' [']'] <* "]" | ||
44 | <|> BBStr <$> escapedText ['['] | ||
45 | |||
46 | escapedText' :: [Char] -> Parser Text | ||
47 | escapedText' = option "" . escapedText | ||
48 | |||
49 | escapedText :: [Char] -> Parser Text | ||
50 | escapedText [] = takeText -- No delimiting characters -- parse all remaining input | ||
51 | escapedText cs = recurse $ choice [ takeWhile1 (not . special) -- a series of characters we don't treat as special | ||
52 | , escapeSeq -- an escaped delimiter | ||
53 | , escapeChar' -- the escape character | ||
54 | ] | ||
55 | where | ||
56 | escapeChar = '\\' | ||
57 | special = inClass $ escapeChar : cs | ||
58 | escapeChar' = string $ T.singleton escapeChar | ||
59 | escapeSeq = escapeChar' *> (T.singleton <$> satisfy special) -- escape character followed by a special character (which encludes the escape character) | ||
60 | recurse p = mappend <$> p <*> escapedText' cs -- parse a prefix and optionally append another chunk of escapedText | ||
61 | |||
62 | runTokenizer :: Text -> Maybe [Token] | ||
63 | runTokenizer = either (const Nothing) Just . parseOnly (many token <* endOfInput) | ||
64 | ~~~ | ||
65 | |||
66 | We have now reduced the Problem to `[Token] -> DomTree`. | ||
67 | We quickly see that the structure of the problem is that of a | ||
68 | [fold](https://hackage.haskell.org/package/base/docs/Data-Foldable.html). | ||
69 | |||
70 | Having realised this we require a function of type `Token -> DomTree -> DomTree` to recursively build up our target | ||
71 | structure. | ||
72 | |||
73 | In general we’ll want to not only keep track of the `DomTree` during recursion but also maintain a reference to the | ||
74 | position at which we’ll be inserting new tokens. | ||
75 | This kind of problem is well understood and solved idiomatically by using a | ||
76 | [zipper](https://en.wikipedia.org/wiki/Zipper_(data_structure)) | ||
77 | ([a cursory introduction](http://learnyouahaskell.com/zippers)). | ||
78 | |||
79 | Writing zippers tends to be tedious. We’ll therefore introduce an | ||
80 | [additional intermediate structure](https://hackage.haskell.org/package/containers/docs/Data-Tree.html) for which an | ||
81 | [implementation](https://hackage.haskell.org/package/rosezipper) is available readily. | ||
82 | The morphism from this new structure (`Forest BBLabel`) to our `DomTree` will be almost trivial. | ||
83 | |||
84 | ~~~ {.haskell} | ||
85 | import Data.Tree.Zipper (TreePos, Empty, Full) | ||
86 | import qualified Data.Tree.Zipper as Z | ||
87 | |||
88 | data BBLabel = BBTag Text | ||
89 | | BBPlain Text | ||
90 | |||
91 | rose :: [BBToken] -> Maybe (Forest BBLabel) | ||
92 | rose = Z.toForest <$> foldM (flip rose') (Z.fromForest []) | ||
93 | |||
94 | rose' :: BBToken -> TreePos Empty BBLabel -> Maybe (TreePos Empty BBLabel) | ||
95 | rose' (BBStr t) = return . Z.nextSpace . Z.insert (Node (BBPlain t) []) -- insert a node with no children and move one step to the right in the forest we’re currently viewing | ||
96 | rose' (BBOpen t) = return . Z.children . Z.insert (Node (BBTag t) []) -- insert the node and move into position to insert it's first child | ||
97 | rose' (BBClose t) = close t -- haskell complains if multiple equations for the same function have a differing number of arguments, therefore: 'close' | ||
98 | where | ||
99 | close :: Text -> TreePos Empty BBLabel -> Maybe (TreePos Empty BBLabel) | ||
100 | close tag pos = do | ||
101 | pos' <- Z.parent pos -- fail if we're trying to close a tag that does not have a parent (this indicates imbalanced tags) | ||
102 | let | ||
103 | pTag = (\(BBTag t) -> t) $ Z.label pos' -- yes, this will fail unceremoniously if the parent is not a tag, this poses no problem since we're constructing the structure ourselves. The proof that this failure mode does not occur is left as an exercise for the reader. | ||
104 | guard (pTag == tag) -- The structure shows that this mode of failure (opening tags content does not match the closing tags) is not logically required -- it only serves as a *notification* to the user | ||
105 | return $ Z.nextSpace pos' -- move one level up and to point at the next sibling of the parent | ||
106 | ~~~ | ||
107 | |||
108 | All that is left to do now is present our final morphism: | ||
109 | |||
110 | ~~~ {.haskell} | ||
111 | dom :: Forest BBLabel -> [DomTree] | ||
112 | dom = map dom' | ||
113 | where | ||
114 | dom' (Node (BBPlain t) []) = Content t | ||
115 | dom' (Node (BBTag t) ts = Element t $ map dom' ts | ||
116 | ~~~ | ||
diff --git a/provider/posts/thermoprint/5.md b/provider/posts/thermoprint/5.md new file mode 100644 index 0000000..0249734 --- /dev/null +++ b/provider/posts/thermoprint/5.md | |||
@@ -0,0 +1,198 @@ | |||
1 | --- | ||
2 | title: Building an Extensible Framework for Specifying Compile-Time Configuration using Universal Quantification | ||
3 | tags: Thermoprint | ||
4 | published: 2016-02-18 | ||
5 | --- | ||
6 | |||
7 | When I write *Universal Quantification* I mean what is commonly referred to as | ||
8 | [existential quantification](https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/data-type-extensions.html#existential-quantification), | ||
9 | which I think is a misnomer. To wit: | ||
10 | |||
11 | $( \exists x \ldotp f(x) ) \to y$ is isomorphic to $\forall x \ldotp (f(x) \to y)$ (I | ||
12 | won´t try to back this claim up with actual category theory just now. You might want to | ||
13 | nag me occasionally if this bothers you -- I really should invest some more time into | ||
14 | category theory). Since haskell does not support `exists` we´re required to use the | ||
15 | `forall`-version, which really is universally quantified. | ||
16 | |||
17 | ## Printer Configuration | ||
18 | |||
19 | What we want is to have the user provide us with a set of specifications of how to | ||
20 | interact with one printer each. | ||
21 | Something like the following: | ||
22 | |||
23 | ~~~ {.haskell} | ||
24 | newtype PrinterMethod = PM { unPM :: Printout -> IO (Maybe PrintingError) } | ||
25 | |||
26 | data Printer = Printer | ||
27 | { print :: PrinterMethod | ||
28 | , queue :: TVar Queue | ||
29 | } | ||
30 | ~~~ | ||
31 | |||
32 | The first step in refining this is necessitated by having the user provide the | ||
33 | [monad-transformer-stack](http://book.realworldhaskell.org/read/monad-transformers.html) | ||
34 | to use at compile time. | ||
35 | Thus we introduce our first universal quantification (in conjunction with | ||
36 | [polymorphic components](https://prime.haskell.org/wiki/PolymorphicComponents)) -- this | ||
37 | one is not isomorphic to an existential one: | ||
38 | |||
39 | ~~~ {.haskell} | ||
40 | newtype PrinterMethod = PM { unPm :: forall m. MonadResource m => Printout -> m (Maybe PrintingError) } | ||
41 | ~~~ | ||
42 | |||
43 | Since we don´t want to *burden* the user with the details of setting up `TVar Queue`{.haskell} we | ||
44 | also introduce function to help with that: | ||
45 | |||
46 | ~~~ {.haskell} | ||
47 | printer :: MonadResource m => PrinterMethod -> m Printer | ||
48 | printer p = Printer p <$> liftIO (newTVarIO def) | ||
49 | ~~~ | ||
50 | |||
51 | We could at this point provide ways to set up `PrinterMethod`{.haskell}s and have the user | ||
52 | provide us with a list of them. | ||
53 | |||
54 | We, however, have numerous examples of printers which require some setup (such opening a | ||
55 | file descriptor). The idiomatic way to handle this is to decorate that setup with some | ||
56 | constraints and construct our list of printers in an | ||
57 | [`Applicative`{.haskell}](https://hackage.haskell.org/package/base/docs/Control-Applicative.html#t:Applicative) | ||
58 | fashion: | ||
59 | |||
60 | ~~~ {.haskell} | ||
61 | printer :: MonadResource m => m PrinterMethod -> m Printer | ||
62 | printer p = Printer <$> p <*> liftIO (newTVarIO def) | ||
63 | ~~~ | ||
64 | |||
65 | At this point a toy implementation of a printer we might provide looks like this: | ||
66 | |||
67 | ~~~ {.haskell} | ||
68 | debugPrint :: Applicative m => m PrinterMethod | ||
69 | debugPrint = pure . PM $ const return Nothing <=< liftIO . putStrLn . toString | ||
70 | |||
71 | toString :: Printout -> String | ||
72 | toString = undefined | ||
73 | ~~~ | ||
74 | |||
75 | ## Management of Printer Queues | ||
76 | |||
77 | We would like the user to be able to modify the printer queues we maintain in arbitrary | ||
78 | ways. | ||
79 | The motivation for this being various cleanup operations such as pruning all successful | ||
80 | jobs older than a few minutes or limiting the size of history to an arbitrary number of | ||
81 | entries. | ||
82 | |||
83 | A pattern for this type of modification of a value residing in a `TVar`{.haskell} might | ||
84 | look like this: | ||
85 | |||
86 | ~~~ {.haskell} | ||
87 | modify :: TVar a -> StateT a STM () -> IO () | ||
88 | modify q f = atomically $ writeTVar =<< runStateT f =<< readTVar q | ||
89 | ~~~ | ||
90 | |||
91 | A rather natural extension of this is to allow what we will henceforth call a | ||
92 | `QueueManager`{.haskell} (currently `StateT a STM ()`{.haskell}) to return an indication | ||
93 | of when it wants to be run again: | ||
94 | |||
95 | ~~~ {.haskell} | ||
96 | type QueueManager = StateT Queue STM Micro | ||
97 | |||
98 | runQM :: QueueManager -> TVar Queue -> IO () | ||
99 | runQM qm q = sleep << qm' | ||
100 | where | ||
101 | qm' = atomically $ (\(a, s) -> a <$ writeTVar q s) =<< runStateT qm =<< readTVar q | ||
102 | sleep (abs -> delay) = threadDelay (fromEnum delay) >> runQM qm q | ||
103 | ~~~ | ||
104 | |||
105 | It stands to reason that sometimes we don't want to run the `QueueManager`{.haskell} ever | ||
106 | again (probably causing the thread running it to terminate). | ||
107 | For doing so we | ||
108 | [extend the real numbers](https://en.wikipedia.org/wiki/Extended_real_number_line) as | ||
109 | represented by `Micro`{.haskell} to | ||
110 | [`Extended Micro`{.haskell}](https://hackage.haskell.org/package/extended-reals): | ||
111 | |||
112 | ~~~ {.haskell} | ||
113 | type QueueManager = StateT Queue STM (Extended Micro) | ||
114 | |||
115 | runQM … | ||
116 | where | ||
117 | … | ||
118 | sleep (abs -> delay) | ||
119 | | (Finite d) <- delay = threadDelay (fromEnum d) >> runQM qm q | ||
120 | | otherwise = return () | ||
121 | ~~~ | ||
122 | |||
123 | `QueueManager`{.haskell}s whose type effectively is `Queue -> STM (Queue, Extended Micro)`{.haskell} | ||
124 | are certainly useful but can carry no state between invocations (which would be useful | ||
125 | e.g. for limiting the rate at which we prune jobs). | ||
126 | |||
127 | Therefore we allow the user to provide an arbitrary monad functor (we use | ||
128 | `MFunctor`{.haskell} from | ||
129 | [mmorph](https://hackage.haskell.org/package/mmorph-1.0.6/docs/Control-Monad-Morph.html#t:MFunctor) | ||
130 | instead of `Servant.Server.Internal.Enter` because | ||
131 | [servant-server](https://hackage.haskell.org/package/servant-server-0.4.4.6/docs/Servant-Server-Internal-Enter.html#v:Nat) | ||
132 | doesn't provide all the tools we require for this) which can carry all the state we could | ||
133 | ever want: | ||
134 | |||
135 | ~~~ {.haskell} | ||
136 | type QueueManager t = QueueManagerM t (Extended Micro) | ||
137 | type QueueManagerM t = ComposeT (StateT Queue) t STM -- 'ComposeT' is required since we need 'QueueManagerM' to have the form 't' STM' for some 't'' in order to be able to use 'lift' | ||
138 | |||
139 | runQM :: (MFunctor t, MonadTrans t, MonadIO (t IO), Monad (t STM)) => QueueManager t -> TVar Queue -> t IO () | ||
140 | runQM … -- nearly identical except for a sprinkling of 'lift' | ||
141 | ~~~ | ||
142 | |||
143 | The final touches are to introduce a typeclass `HasQueue`{.haskell} for convenience: | ||
144 | |||
145 | ~~~ {.haskell} | ||
146 | class HasQueue a where | ||
147 | extractQueue :: a -> TVar Queue | ||
148 | |||
149 | instance HasQueue (TVar Queue) where | ||
150 | extractQueue = id | ||
151 | |||
152 | instance HasQueue Printer where | ||
153 | extractQueue = queue | ||
154 | ~~~ | ||
155 | |||
156 | and provide some utility functions for composing `QueueManager`{.haskell}s: | ||
157 | |||
158 | ~~~ {.haskell} | ||
159 | intersection :: (Foldable f, MonadState Queue (QueueManagerM t)) => f (QueueManager t) -> QueueManager t | ||
160 | -- ^ Combine two 'QueueManager's keeping only 'QueueEntry's both managers decide to keep | ||
161 | -- | ||
162 | -- Side effects propagate left to right | ||
163 | |||
164 | idQM :: Monad (QueueManagerM t) => QueueManager t | ||
165 | -- ^ Identity of 'intersect' | ||
166 | |||
167 | union :: (Foldable f, MonadState Queue (QueueManagerM t)) => f (QueueManager t) -> QueueManager t | ||
168 | -- ^ Combine two 'QueueManager's keeping all 'QueueEntry's either of the managers decides to keep | ||
169 | -- | ||
170 | -- Side effects propagate left to right | ||
171 | |||
172 | nullQM :: MonadState Queue (QueueManagerM t) => QueueManager t | ||
173 | -- ^ Identity of 'union' | ||
174 | ~~~ | ||
175 | |||
176 | We merge the effects of two `QueueManager`{.haskell}s by converting the resulting | ||
177 | `Queue`{.haskell}s to `Set`{.haskell}s and using `Set.union`{.haskell} and | ||
178 | `Set.intersection`{.haskell} with appropriate `Ord`{.haskell} and `Eq`{.haskell} | ||
179 | instances. | ||
180 | |||
181 | ### Configuration of `QueueManager`{.haskell}s | ||
182 | |||
183 | A `QueueManager`{.haskell}s configuration shall be a `QueueManager t`{.haskell} associated | ||
184 | with a specification of how to collapse its monad transformer `t`{.haskell}. | ||
185 | Using universal quantification this is straightforward: | ||
186 | |||
187 | ~~~ {.haskell} | ||
188 | data QMConfig m = forall t. ( MonadTrans t | ||
189 | , MFunctor t | ||
190 | , Monad (t STM) | ||
191 | , MonadIO (t IO) | ||
192 | ) => QMConfig { manager :: QueueManager t | ||
193 | , collapse :: (t IO) :~> m | ||
194 | } | ||
195 | |||
196 | runQM' :: Printer -> QMConfig m -> m () | ||
197 | runQM' printer (QMConfig qm nat) = unNat nat $ runQM qm printer | ||
198 | ~~~ | ||
diff --git a/provider/posts/thermoprint/6.lhs b/provider/posts/thermoprint/6.lhs new file mode 100644 index 0000000..9182427 --- /dev/null +++ b/provider/posts/thermoprint/6.lhs | |||
@@ -0,0 +1,142 @@ | |||
1 | --- | ||
2 | title: Deriving a Client Library for Interacting with Character-Oriented Printers | ||
3 | tags: Thermoprint | ||
4 | published: 2016-02-18 | ||
5 | --- | ||
6 | |||
7 | > {-# LANGUAGE DataKinds #-} | ||
8 | > {-# LANGUAGE TypeOperators #-} | ||
9 | > {-# LANGUAGE ViewPatterns #-} | ||
10 | > {-# LANGUAGE RecordWildCards #-} | ||
11 | > | ||
12 | > -- | A client library for 'Thermoprint.API' | ||
13 | > module Thermoprint.Client | ||
14 | > ( Client(..) | ||
15 | > , mkClient, mkClient' | ||
16 | > , throwNat, ioNat | ||
17 | > -- = Reexports | ||
18 | > , ServantError(..) | ||
19 | > , module Servant.Common.BaseUrl | ||
20 | > , module Control.Monad.Trans.Either | ||
21 | > , module Servant.Server.Internal.Enter | ||
22 | > ) where | ||
23 | > | ||
24 | > import Thermoprint.API | ||
25 | > import Data.Map (Map) | ||
26 | > import Data.Sequence (Seq) | ||
27 | > import Data.Time (UTCTime) | ||
28 | > | ||
29 | > import Servant.Client hiding (HasClient(..)) | ||
30 | > import qualified Servant.Client as S | ||
31 | > import Servant.Common.BaseUrl | ||
32 | > import Servant.API | ||
33 | > import Servant.Server.Internal.Enter | ||
34 | > import Control.Monad.Trans.Either | ||
35 | > | ||
36 | > import Control.Monad.Catch (Exception, MonadThrow(..)) | ||
37 | > import Control.Monad.IO.Class (MonadIO(..)) | ||
38 | > | ||
39 | > import Control.Monad | ||
40 | > import Control.Category | ||
41 | > import Prelude hiding (id, (.)) | ||
42 | > | ||
43 | > instance Exception ServantError | ||
44 | |||
45 | We encapsulate all api operations in a single record parametrized over the monad we intend to use | ||
46 | them in. | ||
47 | Construction of such a record is pure since all we require to do so is a `BaseUrl`{.haskell}. | ||
48 | Using RecordWildCards we can bring all operations into scope with extreme ease. | ||
49 | |||
50 | > -- | All 'ThermoprintAPI'-functions as a record | ||
51 | > -- | ||
52 | > -- Use like this: | ||
53 | > -- | ||
54 | > -- > {-# LANGUAGE RecordWildCards #-} | ||
55 | > -- > | ||
56 | > -- > main :: IO () | ||
57 | > -- > -- ^ Display a list of printers with their status | ||
58 | > -- > main = print =<< printers | ||
59 | > -- > where Client{..} = mkClient' $ Http "localhost" 3000 | ||
60 | > data Client m = Client | ||
61 | > { printers :: m (Map PrinterId PrinterStatus) | ||
62 | > -- ^ List all printers | ||
63 | > , jobs :: Maybe PrinterId | ||
64 | > -> Maybe (Range (JobId)) | ||
65 | > -> Maybe (Range (UTCTime)) | ||
66 | > -> m (Seq (JobId, UTCTime, JobStatus)) | ||
67 | > -- ^ List a selection of jobs | ||
68 | > , jobCreate :: Maybe PrinterId -> Printout -> m JobId | ||
69 | > -- ^ Send a 'Printout' to be queued | ||
70 | > , job :: JobId -> m Printout | ||
71 | > -- ^ Retrieve the contents of a job | ||
72 | > , jobStatus :: JobId -> m JobStatus | ||
73 | > -- ^ Query a jobs status | ||
74 | > , jobDelete :: JobId -> m () | ||
75 | > -- ^ Delete a job from the queue (not from history or while it is being printed) | ||
76 | > , drafts :: m (Map DraftId (Maybe DraftTitle)) | ||
77 | > -- ^ List all saved drafts | ||
78 | > , draftCreate :: Maybe DraftTitle | ||
79 | > -> Printout | ||
80 | > -> m DraftId | ||
81 | > -- ^ Create a new draft | ||
82 | > , draftReplace :: DraftId | ||
83 | > -> Maybe DraftTitle | ||
84 | > -> Printout | ||
85 | > -> m () | ||
86 | > -- ^ Replace the contents and title of an existing draft | ||
87 | > , draft :: DraftId -> m (Maybe DraftTitle, Printout) | ||
88 | > -- ^ Retrieve the contents and title of a draft | ||
89 | > , draftDelete :: DraftId -> m () | ||
90 | > -- ^ Delete a draft | ||
91 | > , draftPrint :: DraftId -> Maybe PrinterId -> m JobId | ||
92 | > -- ^ Send a draft to be printed | ||
93 | > } | ||
94 | |||
95 | [servant documentation](https://haskell-servant.github.io/tutorial/server.html#nested-apis) | ||
96 | advises factoring out apis to make the specification more concise. | ||
97 | We are rightly advised that doing so has an effect on the types of the | ||
98 | corresponding `Server`{.haskell}s and `Client`{.haskell}s. | ||
99 | To cope with this we introduce a helper function that allows us, when | ||
100 | used with ViewPatterns, to nontheless simply pattern match on `client`{.haskell}. | ||
101 | |||
102 | > withArgs :: (x -> a :<|> b) -> ((x -> a) :<|> (x -> b)) | ||
103 | > -- ^ Undo factoring of APIs | ||
104 | > withArgs subAPI = (\(a :<|> _) -> a) . subAPI :<|> (\(_ :<|> b) -> b) . subAPI | ||
105 | |||
106 | `withArgs`{.haskell} as presented here does not recurse and thus | ||
107 | doesn't handle more than one occurence of `:<|>`{.haskell}. | ||
108 | We have to to so ourselves using nested ViewPatterns (see | ||
109 | `mkClient`{.haskell}). | ||
110 | |||
111 | > mkClient :: (EitherT ServantError IO :~> m) -- ^ A monad functor ('Nat') used to make the api functions work in any monad which can do 'IO' and handle errors | ||
112 | > -> BaseUrl | ||
113 | > -> Client m | ||
114 | > -- ^ Generate a 'Client' | ||
115 | |||
116 | RecordWildCards also allows us to construct a record from components | ||
117 | in scope. | ||
118 | |||
119 | > mkClient n url = Client{..} | ||
120 | > where | ||
121 | > printers | ||
122 | > :<|> (jobs :<|> jobCreate) | ||
123 | > :<|> (withArgs -> job :<|> (withArgs -> jobStatus :<|> jobDelete)) | ||
124 | > :<|> (drafts :<|> draftCreate) | ||
125 | > :<|> (withArgs -> draftReplace :<|> (withArgs -> draft :<|> (withArgs -> draftDelete :<|> draftPrint))) | ||
126 | > = enter n $ client thermoprintAPI url | ||
127 | |||
128 | We also provide some additional convenience functions so the user | ||
129 | doesn't have to construct their own `Nat`{.haskell}ural | ||
130 | transformations. | ||
131 | |||
132 | > mkClient' :: (MonadThrow m, MonadIO m) => BaseUrl -> Client m | ||
133 | > -- ^ @mkClient' = mkClient $ ioNat . throwNat@ | ||
134 | > mkClient' = mkClient $ ioNat . throwNat | ||
135 | > | ||
136 | > throwNat :: (Exception e, MonadThrow m) => EitherT e m :~> m | ||
137 | > -- ^ Squash a layer of 'EitherT' into the underlying monad supporting 'throwM' | ||
138 | > throwNat = Nat $ either throwM return <=< runEitherT | ||
139 | > | ||
140 | > ioNat :: MonadIO m => IO :~> m | ||
141 | > -- ^ @ioNat = Nat liftIO@ | ||
142 | > ioNat = Nat liftIO | ||