diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,22 @@
+0.3.0.0
+=======
+
+  * `restart` does not send requests to `$jenkins_url/restart` anymore. Instead, it calls
+  `$jenkins_url/safe-restart` which waits running jobs to complete. New `forceRestart` function
+  does now what `restart` did before
+
+  * Massive refactoring
+
+  * More optics in `Network.HTTP.Conduit.Lens`
+
+  * Add `overallLoad` and `computer` REST API methods shortcuts
+
 0.2.0.0
 =======
 
-  * Move onto http-conduit 2.0 API
+  * Move onto http-conduit 2.0 API.
 
 0.1.0.0
 =======
 
-Initial release. REST and Discovery APIs support.
+  * Initial release. REST and Discovery APIs support.
diff --git a/bench/Concurrency.hs b/bench/Concurrency.hs
new file mode 100644
--- /dev/null
+++ b/bench/Concurrency.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
+-- | Concurrency benchmark
+--
+-- The benchmark does the folllowing:
+--
+--   * it asks Jenkins instance for accessible jobs
+--
+--   * it sends queries to get jobs descriptions
+--
+--   * it prints all descriptions
+--
+-- The benchmark can be run sequentially or concurrently
+--
+-- See @bench/README.md@ for usage instructions
+module Main (main) where
+
+import           Control.Lens                  -- lens
+import           Control.Lens.Aeson            -- lens-aeson
+import qualified Data.ByteString.Char8 as B    -- bytestring
+import           Data.Text (Text)              -- text
+import           Jenkins.Rest                  -- libjenkins
+import           System.Environment (getArgs)  -- base
+import           System.Exit (exitFailure)     -- base
+import           System.IO (hPutStrLn, stderr) -- base
+
+
+type Aggregate a b = (a -> Jenkins b) -> [a] -> Jenkins [b]
+
+main :: IO ()
+main = do
+  m:host:port:user:pass:_ <- getArgs
+  ds <- descriptions (aggregate m) $
+    ConnectInfo host (read port) (B.pack user) (B.pack pass)
+  case ds of
+    Result ds  -> mapM_ print ds
+    Disconnect -> die "disconnect!"
+    Error e    -> die (show e)
+ where
+  die message = do
+    hPutStrLn stderr message
+    exitFailure
+
+  aggregate :: String -> Aggregate a b
+  aggregate "concurrent" = (concurrentlys .) . map
+  aggregate "sequential" = mapM
+  aggregate _ = error "Unknown mode"
+
+descriptions
+  :: Aggregate Text (Maybe Text)
+  -> ConnectInfo
+  -> IO (Result HttpException [Maybe Text])
+descriptions aggregate settings = runJenkins settings $ do
+  res <- get (json -?- "tree" -=- "jobs[name]")
+  aggregate describe (res ^.. key "jobs".values.key "name"._String)
+
+describe :: Text -> Jenkins (Maybe Text)
+describe name = do
+  desc <- get (job name `as` json -?- "tree" -=- "description")
+  return (desc ^? key "description"._String)
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,14 @@
+# Benchmarks
+
+## `Concurrent.hs`
+
+    ./bench/Concurrency (concurrent|sequential) HOST PORT USERNAME PASSWORD
+
+Measures the concurrency impact on time API queries take.
+
+Concurrency is the most useful if you:
+
+  * Do not use any kind of "proxy" servers (apache2, nginx, etc) and _especially_ https
+  * Try to run queries from the nearest machine possible: network latency matters
+
+Under these conditions, expect to get ~2x speedup from using `concurrent` mode on Jenkins server with ~500 jobs.
diff --git a/example/discover.hs b/example/discover.hs
new file mode 100644
--- /dev/null
+++ b/example/discover.hs
@@ -0,0 +1,29 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Discover Jenkins server on the network
+module Main where
+
+import           Data.Monoid ((<>), mempty) -- base
+import           Data.Text (Text)           -- text
+import qualified Data.Text as T             -- text
+import qualified Data.Text.IO as T          -- text
+import           Jenkins.Discover           -- libjenkins
+import           System.Exit (exitFailure)  -- base
+
+
+main :: IO ()
+main = do
+  -- send discover broadcast with 100ms timeout
+  discoveries <- discover 100000
+  case discoveries of
+    -- no Jenkins responded
+    [] -> exitFailure
+    -- pretty print responses
+    _  -> mapM_ (T.putStrLn . pretty) discoveries
+
+-- | Pretty print Jenkins discovery responses
+pretty :: Discover -> Text
+pretty x = T.unwords $
+  ["Jenkins", version x] ++ maybe mempty (return . between "(" ")") (server_id x) ++ ["at", url x]
+ where
+  between l r t = l <> t <> r
+
diff --git a/example/grep-jobs.hs b/example/grep-jobs.hs
new file mode 100644
--- /dev/null
+++ b/example/grep-jobs.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- | Grep Jenkins instance jobs with Perl regex
+module Main (main) where
+
+import           Control.Lens                              -- lens
+import           Control.Exception.Lens                    -- lens
+import           Control.Lens.Aeson (key, values, _String) -- lens-aeson
+import           Control.Monad                             -- base
+import           Data.String (fromString)                  -- base
+import           Data.Text (Text)                          -- text
+import qualified Data.Text as Text                         -- text
+import qualified Data.Text.IO as Text                      -- text
+import           Jenkins.Rest                              -- libjenkins
+import           System.Environment (getArgs)              -- base
+import           System.Exit.Lens                          -- lens
+import           System.Process (readProcessWithExitCode)  -- process
+import           Text.Printf (printf)                      -- base
+
+
+main :: IO ()
+main = do
+  user:pswd:host:port:regex:_ <- getArgs
+  jobs <- grep regex (ConnectInfo host (read port) (fromString user) (fromString pswd))
+  case jobs of
+    [] -> throwingM _ExitFailure 1
+    _  -> mapM_ Text.putStrLn jobs
+
+-- | Filter matching job names
+grep :: String -> ConnectInfo -> IO [Text]
+grep regex conn = do
+  jobs <- runJenkins conn $
+    get (json -?- "tree" -=- "jobs[name]") <&> \res -> res ^.. key "jobs".values.key "name"._String
+  filterM (match regex) (jobs ^.. _Result.folded)
+
+-- | Match job name again Perl regex
+match :: String -> Text -> IO Bool
+match regex name = do
+  (exitcode, _, _) <-
+    readProcessWithExitCode "perl" ["-n", "-e", printf "/%s/ or die" regex] (Text.unpack name)
+  return $ has _ExitSuccess exitcode
diff --git a/example/rename-jobs.hs b/example/rename-jobs.hs
new file mode 100644
--- /dev/null
+++ b/example/rename-jobs.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- | Rename jobs matching supplied pattern
+module Main where
+
+import           Control.Lens                  -- lens
+import           Control.Lens.Aeson            -- lens-aeson
+import           Control.Monad (when)          -- base
+import qualified Data.ByteString.Char8 as B    -- bytestring
+import           Data.Foldable (for_)          -- base
+import           Data.Function (fix)           -- base
+import           Data.Text (Text)              -- text
+import qualified Data.Text as T                -- text
+import qualified Data.Text.IO as T             -- text
+import           Jenkins.Rest                  -- libjenkins
+import           System.Environment (getArgs)  -- base
+import           System.Exit (exitFailure)     -- base
+import           System.IO (hPutStrLn, stderr) -- base
+
+{-# ANN module ("HLint: ignore Use camelCase" :: String) #-}
+
+
+-- | Program options
+data Options = Options
+  { settings :: ConnectInfo
+  , old      :: Text
+  , new      :: Text
+  }
+
+
+main :: IO ()
+main = do
+  -- more useful help on error
+  host:port:user:pass:o:n:_ <- getArgs
+  let opts = Options (ConnectInfo host (read port) (B.pack user) (B.pack pass)) (T.pack o) (T.pack n)
+  res <- rename opts
+  case res of
+    Result _ -> T.putStrLn "Done."
+    -- disconnected for some reason
+    Disconnect -> die "disconnect!"
+    -- something bad happened, show it!
+    Error e -> die (show e)
+ where
+  die message = do
+    hPutStrLn stderr message
+    exitFailure
+
+-- | Prompt to rename all jobs matching pattern
+rename :: Options -> IO (Result HttpException ())
+rename (Options { settings, old, new }) = runJenkins settings $ do
+  -- get jobs names from jenkins "root" API
+  res <- get (json -?- "tree" -=- "jobs[name]")
+  let jobs = res ^.. key "jobs".values.key "name"._String
+  for_ jobs rename_job
+ where
+  rename_job :: Text -> Jenkins ()
+  rename_job name = when (old `T.isInfixOf` name) $ do
+    let name' = (old `T.replace` new) name
+    -- prompt for every matching job
+    yes <- prompt $ T.unwords ["Rename", name, "to", name', "? [y/n]"]
+    when yes $
+      -- if user agrees then voodoo comes
+      post_ (job name -/- "doRename" -?- "newName" -=- name')
+
+  -- asks user until she enters 'y' or 'n'
+  prompt message = io . fix $ \loop -> do
+    T.putStrLn message
+    res <- T.getLine
+    case T.toUpper res of
+      "Y" -> return True
+      "N" -> return False
+      _   -> loop
diff --git a/example/running-jobs-count.hs b/example/running-jobs-count.hs
new file mode 100644
--- /dev/null
+++ b/example/running-jobs-count.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Count running jobs on Jenkins instance
+--
+-- Usage: count-running-jobs HOST PORT USER APITOKEN
+--
+-- Uses an awful hack, that is inspecting the job ball color. Jenkins sets
+-- it to "blue_anime", meaning "animated blue ball" if job is running
+module Main (main) where
+
+import Control.Lens                      -- lens
+import Control.Lens.Aeson                -- lens-aeson
+import Data.ByteString.Lazy (ByteString) -- bytestring
+import Data.String (fromString)          -- bytestring
+import Jenkins.Rest                      -- libjenkins
+import System.Environment (getArgs)      -- base
+import Text.Printf (printf)              -- base
+
+
+main :: IO ()
+main = do
+  host:port:user:apiToken:_ <- getArgs
+  let creds = ConnectInfo host (read port) (fromString user) (fromString apiToken)
+  jobs <- runJenkins creds getJobs
+  printf "Running jobs count: %d\n" (lengthOf (_Result.running) jobs)
+
+getJobs :: Jenkins ByteString
+getJobs = get (json -?- "tree" -=- "jobs[color]")
+
+running :: Fold ByteString ()
+running = key "jobs".values.key "color"._String.only "blue_anime"
diff --git a/examples/Discover.hs b/examples/Discover.hs
deleted file mode 100644
--- a/examples/Discover.hs
+++ /dev/null
@@ -1,29 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
--- | Discover Jenkins server on the network
-module Main where
-
-import           Data.Monoid ((<>), mempty) -- base
-import           Data.Text (Text)           -- text
-import qualified Data.Text as T             -- text
-import qualified Data.Text.IO as T          -- text
-import           Jenkins.Discover           -- libjenkins
-import           System.Exit (exitFailure)  -- base
-
-
-main :: IO ()
-main = do
-  -- send discover broadcast with 100ms timeout
-  discoveries <- discover 100000
-  case discoveries of
-    -- no Jenkins responded
-    [] -> exitFailure
-    -- pretty print responses
-    _  -> mapM_ (T.putStrLn . pretty) discoveries
-
--- | Pretty print Jenkins discovery responses
-pretty :: Discover -> Text
-pretty x = T.unwords $
-  ["Jenkins", version x] ++ maybe mempty (return . between "(" ")") (server_id x) ++ ["at", url x]
- where
-  between l r t = l <> t <> r
-
diff --git a/examples/Jobs.hs b/examples/Jobs.hs
deleted file mode 100644
--- a/examples/Jobs.hs
+++ /dev/null
@@ -1,73 +0,0 @@
-{-# LANGUAGE NamedFieldPuns #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | Show jobs status
-module Main where
-
-import           Control.Lens                      -- lens
-import           Control.Lens.Aeson                -- lens-aeson
-import qualified Data.ByteString.Char8 as B        -- bytestring
-import           Data.Text (Text)                  -- text
-import qualified Data.Text.IO as T                 -- text
-import           Jenkins.REST hiding (render)      -- libjenkins
-import           Options.Applicative               -- optparse-applicative
-import           System.Console.ANSI               -- ansi-terminal
-import           System.Exit (exitFailure)         -- base
-
-{-# ANN module ("HLint: Use camelCase" :: String) #-}
-
-
--- | Job name and status
-data Job = Job
-  { name  :: Text
-  , color :: Color
-  }
-
-
-main :: IO ()
-main = do
-  -- more useful help on error
-  opts <- customExecParser (prefs showHelpOnError) options
-  -- get all jobs (colored)
-  jobs <- colorized_jobs opts
-  case jobs of
-    -- render them
-    Right js -> mapM_ render js
-    -- something bad happened, show it!
-    Left  e  -> do
-      print e
-      exitFailure
-
--- get jobs names from jenkins "root" API
-colorized_jobs :: Settings -> IO (Either Disconnect [Job])
-colorized_jobs settings = runJenkins settings $ do
-  res <- get (json -?- "tree" -=- "jobs[name]")
-  let jobs = res ^.. key "jobs"._Array.each.key "name"._String
-  concurrentlys (map colorize jobs)
-
--- get jobs colors as they appear on web UI
-colorize :: Text -> Jenkins Job
-colorize name = do
-  res <- get (job name `as` json -?- "tree" -=- "color")
-  return . Job name $ case res ^? key "color" of
-    -- but sane
-    Just "red"  -> Red
-    Just "blue" -> Green
-    _           -> Yellow
-
--- render colored job (assumes ANSI terminal)
-render :: Job -> IO ()
-render Job { name, color } = do
-  setSGR [SetColor Foreground Dull color]
-  T.putStrLn name
-  setSGR []
-
-
--- | Quite a trivial jenkins settings parser
-options :: ParserInfo Settings
-options = info (helper <*> parser) fullDesc
- where
-  parser = Settings
-    <$> (Host <$> strOption (long "host"))
-    <*> (Port <$> option (long "port"))
-    <*> (User . B.pack <$> strOption (long "user"))
-    <*> (APIToken . B.pack <$> strOption (long "token" <> long "password"))
diff --git a/examples/Rename.hs b/examples/Rename.hs
deleted file mode 100644
--- a/examples/Rename.hs
+++ /dev/null
@@ -1,81 +0,0 @@
-{-# LANGUAGE NamedFieldPuns #-}
-{-# LANGUAGE OverloadedStrings #-}
--- | Rename jobs matching supplied pattern
-module Main where
-
-import           Control.Lens                      -- lens
-import           Control.Lens.Aeson                -- lens-aeson
-import           Control.Monad (when)              -- base
-import qualified Data.ByteString.Char8 as B        -- bytestring
-import           Data.Foldable (for_)              -- base
-import           Data.Function (fix)               -- base
-import           Data.Text (Text)                  -- text
-import qualified Data.Text as T                    -- text
-import qualified Data.Text.IO as T                 -- text
-import           Jenkins.REST                      -- libjenkins
-import           Options.Applicative               -- optparse-applicative
-import           System.Exit (exitFailure)         -- base
-
-{-# ANN module ("HLint: Use camelCase" :: String) #-}
-
-
-main :: IO ()
-main = do
-  -- more useful help on error
-  opts <- customExecParser (prefs showHelpOnError) options
-  res  <- rename opts
-  case res of
-    Right () -> T.putStrLn "Done."
-    -- something bad happened, show it!
-    Left  e  -> do
-      print e
-      exitFailure
-
--- | Prompt to rename all jobs matching pattern
-rename :: Options -> IO (Either Disconnect ())
-rename (Options { settings, old, new }) = runJenkins settings $ do
-  -- get jobs names from jenkins "root" API
-  res <- get (json -?- "tree" -=- "jobs[name]")
-  let jobs = res ^.. key "jobs"._Array.each.key "name"._String
-  for_ jobs rename_job
- where
-  rename_job :: Text -> Jenkins ()
-  rename_job name = when (old `T.isInfixOf` name) $ do
-    let name' = (old `T.replace` new) name
-    -- prompt for every matching job
-    yes <- prompt $ T.unwords ["Rename", name, "to", name', "? [y/n]"]
-    when yes $
-      -- if user agrees then voodoo comes
-      post_ (job name -/- "doRename" -?- "newName" -=- name')
-
-  -- asks user until she enters 'y' or 'n'
-  prompt message = io . fix $ \loop -> do
-    T.putStrLn message
-    res <- T.getLine
-    case T.toUpper res of
-      "Y" -> return True
-      "N" -> return False
-      _   -> loop
-
-
--- | Program options
-data Options = Options
-  { settings :: Settings
-  , old      :: Text
-  , new      :: Text
-  }
-
--- | Quite a trivial options parser
-options :: ParserInfo Options
-options = info (helper <*> parser) fullDesc
- where
-  parser = Options
-    <$> parse_settings
-    <*> nullOption (reader (return . T.pack) <> long "old")
-    <*> nullOption (reader (return . T.pack) <> long "new")
-
-  parse_settings = Settings
-    <$> (Host <$> strOption (long "host"))
-    <*> (Port <$> option (long "port"))
-    <*> (User . B.pack <$> strOption (long "user"))
-    <*> (APIToken . B.pack <$> strOption (long "token" <> long "password"))
diff --git a/libjenkins.cabal b/libjenkins.cabal
--- a/libjenkins.cabal
+++ b/libjenkins.cabal
@@ -1,5 +1,5 @@
 name:                libjenkins
-version:             0.2.0.0
+version:             0.3.0.0
 synopsis:            Jenkins API interface
 description:         Jenkins API interface. It supports REST and Discovery APIs
 license:             BSD3
@@ -11,9 +11,12 @@
 extra-source-files:
   README.md
   CHANGELOG.md
-  examples/Jobs.hs
-  examples/Rename.hs
-  examples/Discover.hs
+  bench/Concurrency.hs
+  bench/README.md
+  example/grep-jobs.hs
+  example/rename-jobs.hs
+  example/discover.hs
+  example/running-jobs-count.hs
 cabal-version:       >= 1.10
 
 library
@@ -21,17 +24,17 @@
   hs-source-dirs:    src
   exposed-modules:
     Jenkins.Discover
-    Jenkins.REST
-    Jenkins.REST.Internal
-    Jenkins.REST.Lens
-    Jenkins.REST.Method
+    Jenkins.Rest
+    Jenkins.Rest.Internal
+    Jenkins.Rest.Method
+    Network.HTTP.Conduit.Lens
   build-depends:
       async         >= 2.0
-    , base          >= 4.5 && < 5
+    , base          >= 4.6 && < 5
     , bytestring    >= 0.9
     , conduit       >= 1.0
-    , data-default  >= 0.5
     , free          >= 4.1
+    , http-client   >= 0.2.0.2
     , http-conduit  >= 2.0 && < 2.1
     , http-types    >= 0.8
     , lens          >= 3.9
@@ -42,17 +45,31 @@
     , xml-conduit   >= 1.1
     , xml-lens      >= 0.1
 
-test-suite doctests
+test-suite doctest
   default-language:  Haskell2010
   type:              exitcode-stdio-1.0
   build-depends:
-      base == 4.*
+      base          == 4.*
     , directory
     , doctest
     , filepath
-  hs-source-dirs:    tests
-  main-is:           Doctests.hs
+  hs-source-dirs:    test
+  main-is:           Doctest.hs
 
+test-suite spec
+  default-language:  Haskell2010
+  type:              exitcode-stdio-1.0
+  build-depends:
+      base          == 4.*
+    , bytestring
+    , hspec
+    , libjenkins
+    , transformers
+  hs-source-dirs:    test
+  main-is:           Spec.hs
+  other-modules:
+    Jenkins.RestSpec
+
 source-repository head
   type:     git
   location: https://github.com/supki/libjenkins
@@ -60,4 +77,4 @@
 source-repository this
   type:     git
   location: https://github.com/supki/libjenkins
-  tag:      0.2.0.0
+  tag:      0.3.0.0
diff --git a/src/Jenkins/REST.hs b/src/Jenkins/REST.hs
deleted file mode 100644
--- a/src/Jenkins/REST.hs
+++ /dev/null
@@ -1,201 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveDataTypeable #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE TemplateHaskell #-}
--- | Jenkins REST API interface
-module Jenkins.REST
-  ( -- * Types
-    Jenkins, Disconnect(..)
-  , Settings(..), Host(..), Port(..), User(..), APIToken(..), Password
-  , Request
-    -- * Jenkins queries construction
-  , get, post, post_, concurrently, io, disconnect, with
-    -- ** Jenkins method construction
-  , module Jenkins.REST.Method
-    -- ** Little helpers
-  , concurrentlys, concurrentlys_, postXML, reload, restart
-    -- * Jenkins queries execution
-  , runJenkins, runJenkinsP
-    -- ** Lenses and prisms
-  , jenkins_host, jenkins_port, jenkins_user, jenkins_api_token, jenkins_password
-  , _Host, _Port, _User, _APIToken, _Password
-    -- * Usable @http-conduit@ 'Request' type API
-  , module Jenkins.REST.Lens
-  ) where
-
-import           Control.Applicative ((<$))
-import           Control.Exception (handle)
-import           Control.Lens
-import           Control.Monad.IO.Class (MonadIO(..))
-import           Control.Monad.Trans.Maybe (MaybeT(..))
-import           Control.Monad.Trans.Reader (ReaderT, runReaderT)
-import qualified Data.ByteString as B
-import qualified Data.ByteString.Lazy as BL
-import           Data.Data (Data, Typeable)
-import           Data.Default (Default(..))
-import           Data.Monoid (mempty)
-import           Data.String (IsString)
-import           GHC.Generics (Generic)
-import           Network.HTTP.Conduit (Request, HttpException, withManager, applyBasicAuth, parseUrl)
-import           Text.XML (Document, renderLBS)
-
-import           Jenkins.REST.Internal
-import qualified Jenkins.REST.Lens
-import           Jenkins.REST.Lens as L
-import           Jenkins.REST.Method
-
-{-# ANN module ("HLint: Use const" :: String) #-}
-
-
--- | @GET@ query
-get :: Method Complete f -> Jenkins BL.ByteString
-get m = liftJ $ Get m id
-{-# INLINE get #-}
-
--- | @POST@ query (with payload)
-post :: (forall f. Method Complete f) -> BL.ByteString -> Jenkins ()
-post m body = liftJ $ Post m body (\_ -> ())
-{-# INLINE post #-}
-
--- | @POST@ query (without payload)
-post_ :: (forall f. Method Complete f) -> Jenkins ()
-post_ m = post m mempty
-{-# INLINE post_ #-}
-
--- | Do both queries 'concurrently'
-concurrently :: Jenkins a -> Jenkins b -> Jenkins (a, b)
-concurrently ja jb = liftJ $ Conc ja jb (,)
-{-# INLINE concurrently #-}
-
--- | Lift arbitrary 'IO' action
-io :: IO a -> Jenkins a
-io = liftIO
-{-# INLINE io #-}
-
--- | Disconnect from Jenkins
---
--- Following queries won't be executed
-disconnect :: Jenkins a
-disconnect = liftJ Dcon
-{-# INLINE disconnect #-}
-
--- | Make custom local changes to 'Request'
-with :: (Request -> Request) -> Jenkins a -> Jenkins a
-with f j = liftJ $ With f j id
-{-# INLINE with #-}
-
-
--- * Convenience
-
--- | @POST@ job's @config.xml@ (in @xml-conduit@ format)
-postXML :: (forall f. Method Complete f) -> Document -> Jenkins ()
-postXML m =
-  with (requestHeaders <>~ [("Content-Type", "text/xml")]) . post m . renderLBS def
-{-# INLINE postXML #-}
-
--- | Do a list of queries 'concurrently'
-concurrentlys :: [Jenkins a] -> Jenkins [a]
-concurrentlys = foldr go (return [])
- where
-  go x xs = do
-    (y, ys) <- concurrently x xs
-    return (y : ys)
-{-# INLINE concurrentlys #-}
-
--- | Do a list of queries 'concurrently' ignoring their results
-concurrentlys_ :: [Jenkins a] -> Jenkins ()
-concurrentlys_ = foldr (\x xs -> () <$ concurrently x xs) (return ())
-{-# INLINE concurrentlys_ #-}
-
--- | Reload jenkins configuration from disk
-reload :: Jenkins a
-reload = do
-  post_ "reload"
-  disconnect
-{-# INLINE reload #-}
-
--- | Restart jenkins
-restart :: Jenkins a
-restart = do
-  post_ "restart"
-  disconnect
-{-# INLINE restart #-}
-
-
--- | Jenkins settings
---
--- '_jenkins_api_token' might as well be the user's password, Jenkins
--- does not make any distinction between these concepts
-data Settings = Settings
-  { _jenkins_host      :: Host
-  , _jenkins_port      :: Port
-  , _jenkins_user      :: User
-  , _jenkins_api_token :: APIToken
-  } deriving (Show, Eq, Typeable, Data, Generic)
-
-instance Default Settings where
-  def = Settings
-    { _jenkins_host      = "http://example.com"
-    , _jenkins_port      = 80
-    , _jenkins_user      = "anonymous"
-    , _jenkins_api_token = "secret"
-    }
-
--- | Jenkins host address
-newtype Host = Host { unHost :: String }
-  deriving (Show, Eq, Typeable, Data, Generic, IsString)
-
--- | Jenkins port
-newtype Port = Port { unPort :: Int }
-  deriving (Show, Eq, Typeable, Data, Generic, Num)
-
--- | Jenkins user
-newtype User = User { unUser :: B.ByteString }
-  deriving (Show, Eq, Typeable, Data, Generic, IsString)
-
--- | Jenkins user API token
-newtype APIToken = APIToken { unAPIToken :: B.ByteString }
-  deriving (Show, Eq, Typeable, Data, Generic, IsString)
-
--- | Jenkins user password
-type Password = APIToken
-
-makeLenses ''Settings
-
-jenkins_password :: Lens' Settings Password
-jenkins_password = jenkins_api_token
-{-# INLINE jenkins_password #-}
-
-makePrisms ''Host
-makePrisms ''Port
-makePrisms ''User
-makePrisms ''APIToken
-
-_Password :: Prism' Password B.ByteString
-_Password = _APIToken
-{-# INLINE _Password #-}
-
-
-data Disconnect =
-    Disconnect
-  | JenkinsException HttpException
-    deriving (Show, Typeable, Generic)
-
--- | Communicate with Jenkins REST API
---
--- Catches 'HttpException's thrown by @http-conduit@, but
--- does not catch exceptions thrown by embedded 'IO' actions
-runJenkins :: Settings -> Jenkins a -> IO (Either Disconnect a)
-runJenkins (Settings (Host h) (Port p) (User u) (APIToken t)) jenk =
-  handle (return . Left . JenkinsException) $
-    withManager $ \manager -> do
-      req <- liftIO $ parseUrl h
-      let req' = req
-            & L.port            .~ p
-            & L.responseTimeout .~ Just (20 * 1000000)
-      res <- runReaderT (runMaybeT (runJenkinsIO manager jenk)) (applyBasicAuth u t req')
-      return $ maybe (Left Disconnect) Right res
diff --git a/src/Jenkins/REST/Internal.hs b/src/Jenkins/REST/Internal.hs
deleted file mode 100644
--- a/src/Jenkins/REST/Internal.hs
+++ /dev/null
@@ -1,114 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE NoMonoLocalBinds #-}
-{-# LANGUAGE NoMonomorphismRestriction #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE TypeFamilies #-}
-module Jenkins.REST.Internal where
-
-import           Control.Applicative (Applicative(..))
-import           Control.Concurrent.Async (concurrently)
-import           Control.Exception (toException)
-import           Control.Lens
-import           Control.Monad (join)
-import           Control.Monad.Free.Church (F, iterM, liftF)
-import           Control.Monad.IO.Class (MonadIO(..))
-import           Control.Monad.Trans.Class (lift)
-import           Control.Monad.Trans.Control (MonadTransControl(..))
-import           Control.Monad.Trans.Reader (ReaderT, ask, local)
-import           Control.Monad.Trans.Maybe (MaybeT, mapMaybeT)
-import qualified Data.ByteString.Lazy as BL
-import           Data.Conduit (ResourceT)
-import           Network.HTTP.Conduit
-import           Network.HTTP.Types (Status(..))
-
-import           Jenkins.REST.Lens as L
-import           Jenkins.REST.Method
-
-
--- | Jenkins REST API composable queries
-newtype Jenkins a = Jenkins { unJenkins :: F JenkinsF a }
-  deriving (Functor, Applicative, Monad)
-
-instance MonadIO Jenkins where
-  liftIO = liftJ . IO
-  {-# INLINE liftIO #-}
-
--- | 'JenkinsF' terms
-data JenkinsF a where
-  Get  :: Method Complete f -> (BL.ByteString -> a) -> JenkinsF a
-  Post :: (forall f. Method Complete f) -> BL.ByteString -> (BL.ByteString -> a) -> JenkinsF a
-  Conc :: Jenkins a -> Jenkins b -> (a -> b -> c) -> JenkinsF c
-  IO   :: IO a -> JenkinsF a
-  With :: (Request -> Request) -> Jenkins b -> (b -> a) -> JenkinsF a
-  Dcon :: JenkinsF a
-
-instance Functor JenkinsF where
-  fmap f (Get  m g)      = Get  m      (f . g)
-  fmap f (Post m body g) = Post m body (f . g)
-  fmap f (Conc m n g)    = Conc m n    (\a b -> f (g a b))
-  fmap f (IO a)          = IO (fmap f a)
-  fmap f (With h j g)    = With h j    (f . g)
-  fmap _ Dcon            = Dcon
-  {-# INLINE fmap #-}
-
--- | Lift 'JenkinsF' query to the 'Jenkins' query language
-liftJ :: JenkinsF a -> Jenkins a
-liftJ = Jenkins . liftF
-{-# INLINE liftJ #-}
-
-
-runJenkinsIO
-  :: Manager
-  -> Jenkins a
-  -> MaybeT (ReaderT Request (ResourceT IO)) a
-runJenkinsIO manager = runJenkinsP (jenkinsIO manager)
-{-# INLINE runJenkinsIO #-}
-
--- | Generic Jenkins REST API queries interpreter
---
--- Particularly useful for testing (with @m ≡ 'Identity'@)
-runJenkinsP :: Monad m => (JenkinsF (m a) -> m a) -> Jenkins a -> m a
-runJenkinsP go = iterM go . unJenkins
-{-# INLINE runJenkinsP #-}
-
-jenkinsIO
-  :: Manager
-  -> JenkinsF (MaybeT (ReaderT Request (ResourceT IO)) a)
-  -> MaybeT (ReaderT Request (ResourceT IO)) a
-jenkinsIO manager = go where
-  go (Get m next) = do
-    req <- lift ask
-    let req' = req
-          & L.path   %~ (`slash` render m)
-          & L.method .~ "GET"
-    bs <- lift . lift $ httpLbs req' manager
-    next (responseBody bs)
-  go (Post m body next) = do
-    req <- lift ask
-    let req' = req
-          & L.path          %~ (`slash` render m)
-          & L.method        .~ "POST"
-          & L.requestBody   .~ RequestBodyLBS body
-          & L.redirectCount .~ 0
-          & L.checkStatus   .~ \s@(Status st _) hs cookie_jar ->
-            if 200 <= st && st < 400
-                then Nothing
-                else Just . toException $ StatusCodeException s hs cookie_jar
-    res <- lift . lift $ httpLbs req' manager
-    next (responseBody res)
-  go (Conc jenka jenkb next) = do
-    (a, b) <- liftWith $ \run' -> liftWith $ \run'' -> liftWith $ \run''' ->
-      let run :: Jenkins t -> IO (StT ResourceT (StT (ReaderT Request) (StT MaybeT t)))
-          run = run''' . run'' . run' . runJenkinsIO manager
-      in concurrently (run jenka) (run jenkb)
-    c <- restoreT . restoreT . restoreT $ return a
-    d <- restoreT . restoreT . restoreT $ return b
-    next c d
-  go (IO action) = join (liftIO action)
-  go (With f jenk next) = do
-    res <- mapMaybeT (local f) (runJenkinsIO manager jenk)
-    next res
-  go Dcon = fail "disconnect"
diff --git a/src/Jenkins/REST/Lens.hs b/src/Jenkins/REST/Lens.hs
deleted file mode 100644
--- a/src/Jenkins/REST/Lens.hs
+++ /dev/null
@@ -1,56 +0,0 @@
--- | Lenses for 'Network.HTTP.Conduit' types
-module Jenkins.REST.Lens
-  ( -- * 'Request' lenses
-    method, secure
-  , host, port
-  , path, queryString, requestBody, requestHeaders
-  , redirectCount, checkStatus, responseTimeout
-  ) where
-
-import           Control.Applicative ((<$>))
-import           Control.Exception (SomeException)
-import           Control.Lens
-import           Data.ByteString (ByteString)
-import qualified Network.HTTP.Conduit as H
-import qualified Network.HTTP.Types as H
-
-
--- | HTTP request method, eg GET, POST.
-method :: Lens' H.Request H.Method
-method f req = (\m' -> req { H.method = m' }) <$> f (H.method req)
-
--- | Whether to use HTTPS (ie, SSL).
-secure :: Lens' H.Request Bool
-secure f req = (\s' -> req { H.secure = s' }) <$> f (H.secure req)
-
-host :: Lens' H.Request ByteString
-host f req = (\h' -> req { H.host = h' }) <$> f (H.host req)
-
-port :: Lens' H.Request Int
-port f req = (\p' -> req { H.port = p' }) <$> f (H.port req)
-
--- | Everything from the host to the query string.
-path :: Lens' H.Request ByteString
-path f req = (\p' -> req { H.path = p' }) <$> f (H.path req)
-
-queryString :: Lens' H.Request ByteString
-queryString f req = (\qs' -> req { H.queryString = qs' }) <$> f (H.queryString req)
-
-requestBody :: Lens' H.Request H.RequestBody
-requestBody f req = (\rb' -> req { H.requestBody = rb' }) <$> f (H.requestBody req)
-
-requestHeaders :: Lens' H.Request H.RequestHeaders
-requestHeaders f req = (\rh' -> req { H.requestHeaders = rh' }) <$> f (H.requestHeaders req)
-
--- | How many redirects to follow when getting a resource. 0 means follow no redirects. Default value: 10.
-redirectCount :: Lens' H.Request Int
-redirectCount f req = (\rc' -> req { H.redirectCount = rc' }) <$> f (H.redirectCount req)
-
--- | Check the status code. Note that this will run after all redirects are performed.
--- Default: return a StatusCodeException on non-2XX responses.
-checkStatus :: Lens' H.Request (H.Status -> H.ResponseHeaders -> H.CookieJar -> Maybe SomeException)
-checkStatus f req = (\cs' -> req { H.checkStatus = cs' }) <$> f (H.checkStatus req)
-
--- | Number of microseconds to wait for a response. If Nothing, will wait indefinitely. Default: 5 seconds.
-responseTimeout :: Lens' H.Request (Maybe Int)
-responseTimeout f req = (\rt' -> req { H.responseTimeout = rt' }) <$> f (H.responseTimeout req)
diff --git a/src/Jenkins/REST/Method.hs b/src/Jenkins/REST/Method.hs
deleted file mode 100644
--- a/src/Jenkins/REST/Method.hs
+++ /dev/null
@@ -1,278 +0,0 @@
-{-# LANGUAGE CPP #-}
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveDataTypeable #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE KindSignatures #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE StandaloneDeriving #-}
-{-# LANGUAGE UndecidableInstances #-}
--- | Jenkins REST API method construction
-module Jenkins.REST.Method
-  ( -- * Types
-    Method, Type(..), Format, As
-    -- * Method construction
-  , text, int, (-?-), (-/-), (-=-), (-&-), query
-  , as, JSONy(..), XMLy(..), Pythony(..)
-    -- ** Helpers
-  , job, build, view, queue
-    -- * Rendering
-  , render, slash
-  ) where
-
-import           Data.ByteString (ByteString)
-#if defined(__GLASGOW_HASKELL__) && (__GLASGOW_HASKELL__ < 706)
-import           Data.ByteString.Char8 ()
-#endif
-import qualified Data.ByteString as B
-import           Data.Data (Data, Typeable)
-import           Data.Monoid (Monoid(..), (<>))
-import           Data.String (IsString(..))
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as T
-import           Data.Text (Text)
-import           GHC.Generics (Generic)
-import           Network.URI (escapeURIChar, isUnreserved)
-
--- $setup
--- >>> :set -XOverloadedStrings
-
-
-infix  1 :~?, -?-
-infix  3 :~@, `as`
-infix  7 :~=, -=-
-infixr 5 :~/, -/-, :~&, -&-
--- | Jenkins RESTFul API method encoding
-data Method :: Type -> Format -> * where
-  Empty :: Method t f
-  Text  :: Text -> Method Complete f
-  (:~/)  :: Method Complete f -> Method Complete f -> Method Complete f
-  (:~@)  :: Method Complete f -> As f -> Method Complete f
-  (:~=)  :: Text -> Maybe Text -> Method Query f
-  (:~&)  :: Method Query f -> Method Query f -> Method Query f
-  (:~?)  :: Method Complete f -> Method Query f -> Method Complete f
-
-deriving instance Show (As f) => Show (Method t f)
-
--- | Only to support number literals
-instance Num (Method Complete f) where
-  (+)         = error "Method.(+): not supposed to be used"
-  (*)         = error "Method.(*): not supposed to be used"
-  abs         = error "Method.abs: not supposed to be used"
-  signum      = error "Method.signum: not supposed to be used"
-  fromInteger = fromString . show
-
-instance IsString (Method Complete f) where
-  fromString = Text . T.pack
-
-instance IsString (Method Query f) where
-  fromString str = T.pack str :~= Nothing
-
--- | Method types
-data Type = Query | Complete
-  deriving (Show, Eq, Typeable, Data, Generic)
-
--- | Response formats
-data Format = JSON | XML | Python
-  deriving (Show, Eq, Typeable, Data, Generic)
-
--- | Response format singleton type
-data As :: Format -> * where
-  AsJSON   :: As JSON
-  AsXML    :: As XML
-  AsPython :: As Python
-
-deriving instance Show (As f)
-deriving instance Eq (As f)
-
--- | Convert 'Text' to 'Method'
-text :: Text -> Method Complete f
-text = Text
-
--- | Convert 'Integer' to 'Method'
-int :: Integer -> Method Complete f
-int = fromInteger
-
--- | Append 2 paths
-(-/-) :: Method Complete f -> Method Complete f -> Method Complete f
-(-/-) = (:~/)
-
--- | Append 2 queries
-(-&-) :: Method Query f -> Method Query f -> Method Query f
-(-&-) = (:~&)
-
--- | Make a query
-(-=-) :: Text -> Text -> Method Query f
-x -=- y = x :~= Just y
-
--- | Choose response format
-as :: Method Complete f -> As f -> Method Complete f
-as = (:~@)
-
--- | JSON response format
-class JSONy t where
-  json :: t JSON
-
-instance JSONy As where
-  json = AsJSON
-
-instance t ~ Complete => JSONy (Method t) where
-  json = "" `as` json
-
--- | XML response format
-class XMLy t where
-  xml :: t XML
-
-instance XMLy As where
-  xml = AsXML
-
-instance t ~ Complete => XMLy (Method t) where
-  xml = "" `as` xml
-
--- | Python response format
-class Pythony t where
-  python :: t Python
-
-instance Pythony As where
-  python = AsPython
-
-instance t ~ Complete => Pythony (Method t) where
-  python = "" `as` python
-
--- | Append path and query
-(-?-) :: Method Complete f -> Method Query f -> Method Complete f
-(-?-) = (:~?)
-
--- | list-to-query combinator
---
--- >>> render (query [("foo", Nothing), ("bar", Just "baz"), ("quux", Nothing)])
--- "foo&bar=baz&quux"
---
--- >>> render (query [])
--- ""
-query :: [(Text, Maybe Text)] -> Method Query f
-query [] = Empty
-query xs = foldr1 (:~&) (map (uncurry (:~=)) xs)
-
-
--- | Render 'Method' to something that can be sent over the wire
---
--- >>> render ("" `as` xml)
--- "api/xml"
---
--- >>> render xml
--- "api/xml"
---
--- >>> render ("job" -/- 7 `as` xml)
--- "job/7/api/xml"
---
--- >>> render ("job" -/- 7 `as` xml)
--- "job/7/api/xml"
---
--- >>> render ("job" -/- 7 `as` json)
--- "job/7/api/json"
---
--- >>> render (text "restart")
--- "restart"
---
--- >>> render ("job" -?- "name" -=- "foo" -&- "title" -=- "bar")
--- "job?name=foo&title=bar"
---
--- >>> render ("job" -?- "name" -&- "title" -=- "bar")
--- "job?name&title=bar"
---
--- >>> render ("job" -/- 7 `as` json -?- "name" -&- "title" -=- "bar")
--- "job/7/api/json?name&title=bar"
---
--- >>> render ("job" -/- "ДМИТРИЙ" `as` xml)
--- "job/%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99/api/xml"
-render :: Method t f -> ByteString
-render Empty            = ""
-render (Text s)         = renderText s
-render (x :~/ y)        = render x `slash` render y
-render (x :~@ f)        =
-  let prefix  = render x
-      postfix = renderFormat f
-  in if B.null prefix then  "api" `slash` postfix else prefix `slash` "api" `slash` postfix
-render (x :~= Just y)   = renderText x `equals` renderText y
-render (x :~= Nothing)  = renderText x
-render (x :~& y)        = render x `ampersand` render y
-render (x :~? y)        = render x `question` render y
-
-renderFormat :: IsString s => As f -> s
-renderFormat AsJSON   = "json"
-renderFormat AsXML    = "xml"
-renderFormat AsPython = "python"
-
--- | Render unicode text as query string
---
--- >>> renderText "foo-bar-baz"
--- "foo-bar-baz"
---
--- >>> renderText "foo bar baz"
--- "foo%20bar%20baz"
---
--- >>> renderText "ДМИТРИЙ МАЛИКОВ"
--- "%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99%20%D0%9C%D0%90%D0%9B%D0%98%D0%9A%D0%9E%D0%92"
-renderText :: Text -> ByteString
-renderText = T.encodeUtf8 . T.concatMap (T.pack . escapeURIChar isUnreserved)
-
--- | Insert \"\/\" between two 'String'-like things and concat them.
-slash :: (IsString m, Monoid m) => m -> m -> m
-slash = insert "/"
-
--- | Insert \"=\" between two 'String'-like things and concat them.
-equals :: (IsString m, Monoid m) => m -> m -> m
-equals = insert "="
-
--- | Insert \"&\" between two 'String'-like things and concat them.
-ampersand :: (IsString m, Monoid m) => m -> m -> m
-ampersand = insert "&"
-
--- | Insert \"?\" between two 'String'-like things and concat them.
-question :: (IsString m, Monoid m) => m -> m -> m
-question = insert "?"
-
--- | Insert 'String'-like thing between two 'String'-like things and concat them.
---
--- >>> "foo" `slash` "bar"
--- "foo/bar"
---
--- >>> "" `ampersand` "foo"
--- "&foo"
---
--- >>> "foo" `question` ""
--- "foo?"
-insert :: (IsString m, Monoid m) => m -> m -> m -> m
-insert t x y = x <> t <> y
-
-
--- | Job API method
---
--- >>> render (job "name" `as` json)
--- "job/name/api/json"
-job :: Text -> Method Complete f
-job name = "job" -/- text name
-
--- | Job build API method
---
--- >>> render (build "name" 4 `as` json)
--- "job/name/4/api/json"
-build :: Integral a => Text -> a -> Method Complete f
-build name num = "job" -/- text name -/- int (toInteger num)
-
--- | View API method
---
--- >>> render (view "name" `as` xml)
--- "view/name/api/xml"
-view :: Text -> Method Complete f
-view name = "view" -/- text name
-
--- | Queue API method
---
--- >>> render (queue `as` python)
--- "queue/api/python"
-queue :: Method Complete f
-queue = "queue"
diff --git a/src/Jenkins/Rest.hs b/src/Jenkins/Rest.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/Rest.hs
@@ -0,0 +1,125 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+-- | Jenkins REST API interface
+module Jenkins.Rest
+  ( -- * Query Jenkins
+    runJenkins, ConnectInfo(..), defaultConnectInfo, Jenkins, Result(..)
+    -- ** Combinators
+  , get, post, post_, concurrently, io, disconnect, with
+    -- ** Method
+  , module Jenkins.Rest.Method
+    -- ** Convenience
+  , postXML, concurrentlys, concurrentlys_, reload, restart, forceRestart
+    -- * Lensy things
+  , jenkinsUrl, jenkinsPort, jenkinsUser, jenkinsApiToken, jenkinsPassword
+  , _Error, _Disconnect, _Result
+    -- * Type reexports
+  , Request, HttpException
+  ) where
+
+import Control.Applicative ((<$))
+import Data.Foldable (Foldable, foldr)
+import Control.Lens
+import Control.Monad.IO.Class (MonadIO(..))
+import Data.ByteString.Lazy (ByteString)
+import Data.Monoid (mempty)
+import Network.HTTP.Conduit (Request, HttpException)
+import Prelude hiding (foldr)
+import Text.XML (Document, renderLBS, def)
+
+import Jenkins.Rest.Internal
+import Jenkins.Rest.Method
+import Network.HTTP.Conduit.Lens
+
+{-# ANN module ("HLint: ignore Use const" :: String) #-}
+
+
+-- | @GET@ query
+get :: Method Complete f -> Jenkins ByteString
+get m = liftJ $ Get m id
+{-# INLINE get #-}
+
+-- | @POST@ query (with a payload)
+post :: (forall f. Method Complete f) -> ByteString -> Jenkins ()
+post m body = liftJ $ Post m body (\_ -> ())
+{-# INLINE post #-}
+
+-- | @POST@ query (without payload)
+post_ :: (forall f. Method Complete f) -> Jenkins ()
+post_ m = post m mempty
+{-# INLINE post_ #-}
+
+-- | Do both queries 'concurrently'
+concurrently :: Jenkins a -> Jenkins b -> Jenkins (a, b)
+concurrently ja jb = liftJ $ Conc ja jb (,)
+{-# INLINE concurrently #-}
+
+-- | Lift arbitrary 'IO' action
+io :: IO a -> Jenkins a
+io = liftIO
+{-# INLINE io #-}
+
+-- | Disconnect from Jenkins
+--
+-- Any following queries won't be executed
+disconnect :: Jenkins a
+disconnect = liftJ Dcon
+{-# INLINE disconnect #-}
+
+-- | Make local changes to the 'Request'
+with :: (Request -> Request) -> Jenkins a -> Jenkins a
+with f j = liftJ $ With f j id
+{-# INLINE with #-}
+
+
+-- | @POST@ job's @config.xml@ (or any other xml, really) in @xml-conduit@ format
+postXML :: (forall f. Method Complete f) -> Document -> Jenkins ()
+postXML m =
+  with (requestHeaders <>~ [("Content-Type", "text/xml")]) . post m . renderLBS def
+{-# INLINE postXML #-}
+
+-- | Send a list of queries 'concurrently'
+concurrentlys :: Foldable f => f (Jenkins a) -> Jenkins [a]
+concurrentlys = foldr go (return [])
+ where
+  go x xs = do
+    (y, ys) <- concurrently x xs
+    return (y : ys)
+{-# INLINE concurrentlys #-}
+
+-- | Send a list of queries 'concurrently' ignoring their results
+--
+-- /Note/: exceptions are still raised
+concurrentlys_ :: Foldable f => f (Jenkins a) -> Jenkins ()
+concurrentlys_ = foldr (\x xs -> () <$ concurrently x xs) (return ())
+{-# INLINE concurrentlys_ #-}
+
+-- | Reload jenkins configuration from disk
+--
+-- Calls @/reload@ and disconnects
+reload :: Jenkins a
+reload = do
+  post_ "reload"
+  disconnect
+{-# INLINE reload #-}
+
+-- | Restart jenkins safely
+--
+-- Calls @/safeRestart@ and disconnects
+--
+-- @/safeRestart@ allows all running jobs to complete
+restart :: Jenkins a
+restart = do
+  post_ "safeRestart"
+  disconnect
+{-# INLINE restart #-}
+
+-- | Force jenkins to restart without waiting for running jobs to finish
+--
+-- Calls @/restart@ and disconnects
+forceRestart :: Jenkins a
+forceRestart = do
+  post_ "restart"
+  disconnect
+{-# INLINE forceRestart #-}
diff --git a/src/Jenkins/Rest/Internal.hs b/src/Jenkins/Rest/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/Rest/Internal.hs
@@ -0,0 +1,228 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveDataTypeable #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+-- | Jenkins REST API interface internals
+module Jenkins.Rest.Internal where
+
+import           Control.Applicative
+import           Control.Concurrent.Async (concurrently)
+import           Control.Exception (Exception, try, toException)
+import           Control.Lens
+import           Control.Monad
+import           Control.Monad.Free.Church (F, iterM, liftF)
+import           Control.Monad.IO.Class (MonadIO(..))
+import           Control.Monad.Trans.Class (lift)
+import           Control.Monad.Trans.Control (MonadTransControl(..))
+import           Control.Monad.Trans.Reader (ReaderT, runReaderT, ask, local)
+import           Control.Monad.Trans.Maybe (MaybeT(..), mapMaybeT)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as BL
+import           Data.Conduit (ResourceT)
+import           Data.Data (Data, Typeable)
+import           GHC.Generics (Generic)
+import           Network.HTTP.Conduit
+import           Network.HTTP.Types (Status(..))
+
+import           Jenkins.Rest.Method
+import qualified Network.HTTP.Conduit.Lens as L
+
+
+-- | Jenkins REST API query sequence description
+newtype Jenkins a = Jenkins { unJenkins :: F JenkinsF a }
+  deriving (Functor, Applicative, Monad)
+
+instance MonadIO Jenkins where
+  liftIO = liftJ . IO
+  {-# INLINE liftIO #-}
+
+-- | Jenkins REST API query
+data JenkinsF a where
+  Get  :: Method Complete f -> (BL.ByteString -> a) -> JenkinsF a
+  Post :: (forall f. Method Complete f) -> BL.ByteString -> (BL.ByteString -> a) -> JenkinsF a
+  Conc :: Jenkins a -> Jenkins b -> (a -> b -> c) -> JenkinsF c
+  IO   :: IO a -> JenkinsF a
+  With :: (Request -> Request) -> Jenkins b -> (b -> a) -> JenkinsF a
+  Dcon :: JenkinsF a
+
+instance Functor JenkinsF where
+  fmap f (Get  m g)      = Get  m      (f . g)
+  fmap f (Post m body g) = Post m body (f . g)
+  fmap f (Conc m n g)    = Conc m n    (\a b -> f (g a b))
+  fmap f (IO a)          = IO (fmap f a)
+  fmap f (With h j g)    = With h j    (f . g)
+  fmap _ Dcon            = Dcon
+  {-# INLINE fmap #-}
+
+-- | Lift 'JenkinsF' to 'Jenkins'
+liftJ :: JenkinsF a -> Jenkins a
+liftJ = Jenkins . liftF
+{-# INLINE liftJ #-}
+
+-- | The result of Jenkins REST API queries
+data Result e v =
+    Error e    -- ^ Exception @e@ was thrown while querying
+  | Disconnect -- ^ The client was explicitly disconnected
+  | Result v   -- ^ Querying successfully finished the with value @v@
+    deriving (Show, Eq, Ord, Typeable, Data, Generic)
+
+-- | Query Jenkins API using 'Jenkins' description
+--
+-- Successful result is either 'Disconnect' or @ 'Result' v @
+--
+-- If 'HttpException' was thrown by @http-conduit@, 'runJenkins' catches it
+-- and wraps in 'Error'. Other exceptions are /not/ catched
+runJenkins :: ConnectInfo -> Jenkins a -> IO (Result HttpException a)
+runJenkins (ConnectInfo h p user token) jenk =
+  fmap result . try . withManager $ \manager -> do
+    req <- liftIO $ parseUrl h
+    let req' = req
+          & L.port            .~ p
+          & L.responseTimeout .~ Just (20 * 1000000)
+    runReaderT (runMaybeT (iterJenkinsIO manager jenk)) (applyBasicAuth user token req')
+ where
+  result :: Either e (Maybe v) -> Result e v
+  result (Left e)           = Error e
+  result (Right Nothing)    = Disconnect
+  result (Right (Just val)) = Result val
+
+-- | A prism into Jenkins error
+_Error :: Prism (Result e a) (Result e' a) e e'
+_Error = prism Error $ \case
+  Error e    -> Right e
+  Disconnect -> Left Disconnect
+  Result a   -> Left (Result a)
+{-# INLINE _Error #-}
+
+-- | A prism into disconnect
+_Disconnect :: Prism' (Result e a) ()
+_Disconnect = prism' (\_ -> Disconnect) $ \case
+  Disconnect -> Just ()
+  _          -> Nothing
+{-# INLINE _Disconnect #-}
+
+-- | A prism into result
+_Result :: Prism (Result e a) (Result e b) a b
+_Result = prism Result $ \case
+  Error e    -> Left (Error e)
+  Disconnect -> Left Disconnect
+  Result a   -> Right a
+{-# INLINE _Result #-}
+
+-- | Interpret 'JenkinsF' AST in 'IO'
+iterJenkinsIO
+  :: Manager
+  -> Jenkins a
+  -> MaybeT (ReaderT Request (ResourceT IO)) a
+iterJenkinsIO manager = iterJenkins (interpreter manager)
+{-# INLINE iterJenkinsIO #-}
+
+-- | Tear down 'JenkinsF' AST with a 'JenkinsF'-algebra
+iterJenkins :: Monad m => (JenkinsF (m a) -> m a) -> Jenkins a -> m a
+iterJenkins go = iterM go . unJenkins
+{-# INLINE iterJenkins #-}
+
+-- | 'JenkinsF' AST interpreter
+interpreter
+  :: Manager
+  -> JenkinsF (MaybeT (ReaderT Request (ResourceT IO)) a)
+  -> MaybeT (ReaderT Request (ResourceT IO)) a
+interpreter manager = go where
+  go (Get m next) = do
+    req <- lift ask
+    let req' = req
+          & L.path   %~ (`slash` render m)
+          & L.method .~ "GET"
+    bs <- lift . lift $ httpLbs req' manager
+    next (responseBody bs)
+  go (Post m body next) = do
+    req <- lift ask
+    let req' = req
+          & L.path          %~ (`slash` render m)
+          & L.method        .~ "POST"
+          & L.requestBody   .~ RequestBodyLBS body
+          & L.redirectCount .~ 0
+          & L.checkStatus   .~ \s@(Status st _) hs cookie_jar ->
+            if 200 <= st && st < 400
+                then Nothing
+                else Just . toException $ StatusCodeException s hs cookie_jar
+    res <- lift . lift $ httpLbs req' manager
+    next (responseBody res)
+  go (Conc jenka jenkb next) = do
+    (a, b) <- liftWith $ \run' -> liftWith $ \run'' -> liftWith $ \run''' ->
+      let
+        run :: Jenkins t -> IO (StT ResourceT (StT (ReaderT Request) (StT MaybeT t)))
+        run = run''' . run'' . run' . iterJenkinsIO manager
+      in
+        concurrently (run jenka) (run jenkb)
+    c <- restoreT . restoreT . restoreT $ return a
+    d <- restoreT . restoreT . restoreT $ return b
+    next c d
+  go (IO action) = join (liftIO action)
+  go (With f jenk next) = do
+    res <- mapMaybeT (local f) (iterJenkinsIO manager jenk)
+    next res
+  go Dcon = mzero
+
+
+-- | Jenkins connection settings
+--
+-- '_jenkinsApiToken' may be user's password, Jenkins
+-- does not make any distinction between these concepts
+data ConnectInfo = ConnectInfo
+  { _jenkinsUrl      :: String       -- ^ Jenkins URL, e.g. @http:\/\/example.com\/jenkins@
+  , _jenkinsPort     :: Int          -- ^ Jenkins port, e.g. @8080@
+  , _jenkinsUser     :: B.ByteString -- ^ Jenkins user, e.g. @jenkins@
+  , _jenkinsApiToken :: B.ByteString -- ^ Jenkins user API token
+  } deriving (Show, Eq, Typeable, Data, Generic)
+
+-- | Default Jenkins connection settings
+--
+-- @
+-- defaultConnectInfo = ConnectInfo
+--   { _jenkinsUrl      = \"http:\/\/example.com\/jenkins\"
+--   , _jenkinsPort     = 8080
+--   , _jenkinsUser     = \"jenkins\"
+--   , _jenkinsApiToken = \"\"
+--   }
+-- @
+defaultConnectInfo :: ConnectInfo
+defaultConnectInfo = ConnectInfo
+  { _jenkinsUrl      = "http://example.com/jenkins"
+  , _jenkinsPort     = 8080
+  , _jenkinsUser     = "jenkins"
+  , _jenkinsApiToken = ""
+  }
+
+-- | A lens into Jenkins URL
+jenkinsUrl :: Lens' ConnectInfo String
+jenkinsUrl f req = (\u' -> req { _jenkinsUrl = u' }) <$> f (_jenkinsUrl req)
+{-# INLINE jenkinsUrl #-}
+
+-- | A lens into Jenkins port
+jenkinsPort :: Lens' ConnectInfo Int
+jenkinsPort f req = (\p' -> req { _jenkinsPort = p' }) <$> f (_jenkinsPort req)
+{-# INLINE jenkinsPort #-}
+
+-- | A lens into Jenkins user
+jenkinsUser :: Lens' ConnectInfo B.ByteString
+jenkinsUser f req = (\u' -> req { _jenkinsUser = u' }) <$> f (_jenkinsUser req)
+{-# INLINE jenkinsUser #-}
+
+-- | A lens into Jenkins user API token
+jenkinsApiToken :: Lens' ConnectInfo B.ByteString
+jenkinsApiToken f req = (\a' -> req { _jenkinsApiToken = a' }) <$> f (_jenkinsApiToken req)
+{-# INLINE jenkinsApiToken #-}
+
+-- | A lens into Jenkins password
+--
+-- @
+-- jenkinsPassword = jenkinsApiToken
+-- @
+jenkinsPassword :: Lens' ConnectInfo B.ByteString
+jenkinsPassword = jenkinsApiToken
+{-# INLINE jenkinsPassword #-}
diff --git a/src/Jenkins/Rest/Method.hs b/src/Jenkins/Rest/Method.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/Rest/Method.hs
@@ -0,0 +1,302 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveDataTypeable #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE StandaloneDeriving #-}
+-- | Jenkins REST API method construction
+module Jenkins.Rest.Method
+  ( -- * Types
+    Method
+  , Type(..)
+  , Format
+  , As
+    -- * Method construction
+  , text, int
+  , (-?-), (-/-), (-=-), (-&-)
+  , query
+  , as
+  , JSONy(..)
+  , XMLy(..)
+  , Pythony(..)
+    -- * Shortcuts
+  , job
+  , build
+  , view
+  , queue
+  , overallLoad
+  , computer
+    -- * Rendering
+  , render
+  , slash
+  ) where
+
+import           Data.ByteString (ByteString)
+import qualified Data.ByteString as B
+import           Data.Data (Data, Typeable)
+import           Data.Monoid (Monoid(..), (<>))
+import           Data.String (IsString(..))
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import           Data.Text (Text)
+import           GHC.Generics (Generic)
+import           Network.URI (escapeURIChar, isUnreserved)
+
+-- $setup
+-- >>> :set -XOverloadedStrings
+
+
+infix  1 :~?, -?-
+infix  3 :~@, `as`
+infix  7 :~=, -=-
+infixr 5 :~/, -/-, :~&, -&-
+
+-- | Jenkins RESTFul API method encoding
+data Method :: Type -> Format -> * where
+  Empty :: Method t f
+  Text  :: Text -> Method Complete f
+  (:~/)  :: Method Complete f -> Method Complete f -> Method Complete f
+  (:~@)  :: Method Complete f -> As f -> Method Complete f
+  (:~=)  :: Text -> Maybe Text -> Method Query f
+  (:~&)  :: Method Query f -> Method Query f -> Method Query f
+  (:~?)  :: Method Complete f -> Method Query f -> Method Complete f
+
+deriving instance Show (As f) => Show (Method t f)
+
+-- | Only to support number literals
+instance t ~ Complete => Num (Method t f) where
+  (+)         = error "Method.(+): not supposed to be used"
+  (*)         = error "Method.(*): not supposed to be used"
+  abs         = error "Method.abs: not supposed to be used"
+  signum      = error "Method.signum: not supposed to be used"
+  fromInteger = fromString . show
+
+instance IsString (Method Complete f) where
+  fromString = Text . T.pack
+
+instance IsString (Method Query f) where
+  fromString str = T.pack str :~= Nothing
+
+-- | Method types
+data Type = Query | Complete
+  deriving (Show, Eq, Typeable, Data, Generic)
+
+-- | Response formats
+data Format = JSON | XML | Python
+  deriving (Show, Eq, Typeable, Data, Generic)
+
+-- | Response format singleton type
+data As :: Format -> * where
+  AsJSON   :: As JSON
+  AsXML    :: As XML
+  AsPython :: As Python
+
+deriving instance Show (As f)
+deriving instance Eq (As f)
+
+-- | Convert 'Text' to 'Method'
+text :: Text -> Method Complete f
+text = Text
+
+-- | Convert 'Integer' to 'Method'
+int :: Integer -> Method Complete f
+int = fromInteger
+
+-- | Combine 2 paths
+(-/-) :: Method Complete f -> Method Complete f -> Method Complete f
+(-/-) = (:~/)
+
+-- | Combine 2 queries
+(-&-) :: Method Query f -> Method Query f -> Method Query f
+(-&-) = (:~&)
+
+-- | Make a field-value pair
+(-=-) :: Text -> Text -> Method Query f
+x -=- y = x :~= Just y
+
+-- | Choose response format
+as :: Method Complete f -> As f -> Method Complete f
+as = (:~@)
+
+-- | JSON response format
+class JSONy t where
+  json :: t JSON
+
+instance JSONy As where
+  json = AsJSON
+
+instance t ~ Complete => JSONy (Method t) where
+  json = "" `as` json
+
+-- | XML response format
+class XMLy t where
+  xml :: t XML
+
+instance XMLy As where
+  xml = AsXML
+
+instance t ~ Complete => XMLy (Method t) where
+  xml = "" `as` xml
+
+-- | Python response format
+class Pythony t where
+  python :: t Python
+
+instance Pythony As where
+  python = AsPython
+
+instance t ~ Complete => Pythony (Method t) where
+  python = "" `as` python
+
+-- | Combine path and query
+(-?-) :: Method Complete f -> Method Query f -> Method Complete f
+(-?-) = (:~?)
+
+-- | List-to-query convenience combinator
+--
+-- >>> render (query [("foo", Nothing), ("bar", Just "baz"), ("quux", Nothing)])
+-- "foo&bar=baz&quux"
+--
+-- >>> render (query [])
+-- ""
+query :: [(Text, Maybe Text)] -> Method Query f
+query [] = Empty
+query xs = foldr1 (:~&) (map (uncurry (:~=)) xs)
+
+
+-- | Render 'Method' to something that can be sent over the wire
+--
+-- >>> render ("" `as` xml)
+-- "api/xml"
+--
+-- >>> render xml
+-- "api/xml"
+--
+-- >>> render ("job" -/- 7 `as` xml)
+-- "job/7/api/xml"
+--
+-- >>> render ("job" -/- 7 `as` xml)
+-- "job/7/api/xml"
+--
+-- >>> render ("job" -/- 7 `as` json)
+-- "job/7/api/json"
+--
+-- >>> render (text "restart")
+-- "restart"
+--
+-- >>> render ("job" -?- "name" -=- "foo" -&- "title" -=- "bar")
+-- "job?name=foo&title=bar"
+--
+-- >>> render ("job" -?- "name" -&- "title" -=- "bar")
+-- "job?name&title=bar"
+--
+-- >>> render ("job" -/- 7 `as` json -?- "name" -&- "title" -=- "bar")
+-- "job/7/api/json?name&title=bar"
+--
+-- >>> render ("job" -/- "ДМИТРИЙ" `as` xml)
+-- "job/%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99/api/xml"
+render :: Method t f -> ByteString
+render Empty            = ""
+render (Text s)         = renderText s
+render (x :~/ y)        = render x `slash` render y
+render (x :~@ f)        =
+  let prefix  = render x
+      postfix = renderFormat f
+  in if B.null prefix then  "api" `slash` postfix else prefix `slash` "api" `slash` postfix
+render (x :~= Just y)   = renderText x `equals` renderText y
+render (x :~= Nothing)  = renderText x
+render (x :~& y)        = render x `ampersand` render y
+render (x :~? y)        = render x `question` render y
+
+renderFormat :: IsString s => As f -> s
+renderFormat AsJSON   = "json"
+renderFormat AsXML    = "xml"
+renderFormat AsPython = "python"
+
+-- | Render unicode text as a query string
+--
+-- >>> renderText "foo-bar-baz"
+-- "foo-bar-baz"
+--
+-- >>> renderText "foo bar baz"
+-- "foo%20bar%20baz"
+--
+-- >>> renderText "ДМИТРИЙ МАЛИКОВ"
+-- "%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99%20%D0%9C%D0%90%D0%9B%D0%98%D0%9A%D0%9E%D0%92"
+renderText :: Text -> ByteString
+renderText = T.encodeUtf8 . T.concatMap (T.pack . escapeURIChar isUnreserved)
+
+-- | Insert \"\/\" between two 'String'-like things and concatenate everything.
+slash :: (IsString m, Monoid m) => m -> m -> m
+slash = insert "/"
+
+-- | Insert \"=\" between two 'String'-like things and concatenate everything.
+equals :: (IsString m, Monoid m) => m -> m -> m
+equals = insert "="
+
+-- | Insert \"&\" between two 'String'-like things and concatenate everything.
+ampersand :: (IsString m, Monoid m) => m -> m -> m
+ampersand = insert "&"
+
+-- | Insert \"?\" between two 'String'-like things and concatenate everything.
+question :: (IsString m, Monoid m) => m -> m -> m
+question = insert "?"
+
+-- | Insert 'String'-like thing between two 'String'-like things and concatenate everything.
+--
+-- >>> "foo" `slash` "bar"
+-- "foo/bar"
+--
+-- >>> "" `ampersand` "foo"
+-- "&foo"
+--
+-- >>> "foo" `question` ""
+-- "foo?"
+insert :: (IsString m, Monoid m) => m -> m -> m -> m
+insert t x y = x <> t <> y
+
+
+-- | Job API method
+--
+-- >>> render (job "name" `as` json)
+-- "job/name/api/json"
+job :: Text -> Method Complete f
+job name = "job" -/- text name
+
+-- | Job build API method
+--
+-- >>> render (build "name" 4 `as` json)
+-- "job/name/4/api/json"
+build :: Integral a => Text -> a -> Method Complete f
+build name num = "job" -/- text name -/- int (toInteger num)
+
+-- | View API method
+--
+-- >>> render (view "name" `as` xml)
+-- "view/name/api/xml"
+view :: Text -> Method Complete f
+view name = "view" -/- text name
+
+-- | Queue API method
+--
+-- >>> render (queue `as` python)
+-- "queue/api/python"
+queue :: Method Complete f
+queue = "queue"
+
+-- | Statistics API method
+--
+-- >>> render (overallLoad `as` xml)
+-- "overallLoad/api/xml"
+overallLoad :: Method Complete f
+overallLoad = "overallLoad"
+
+-- | Node API method
+--
+-- >>> render (computer `as` python)
+-- "computer/api/python"
+computer :: Method Complete f
+computer = "computer"
diff --git a/src/Network/HTTP/Conduit/Lens.hs b/src/Network/HTTP/Conduit/Lens.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HTTP/Conduit/Lens.hs
@@ -0,0 +1,359 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+-- | Optics for @http-conduit@ types
+module Network.HTTP.Conduit.Lens
+  ( -- * 'Request' lenses
+    method
+  , secure
+  , host
+  , port
+  , path
+  , queryString
+  , requestBody
+  , requestHeaders
+  , proxy
+  , hostAddress
+  , rawBody
+  , decompress
+  , redirectCount
+  , checkStatus
+  , responseTimeout
+  , cookieJar
+  , getConnectionWrapper
+    -- * 'HttpException' prisms
+  , AsHttpException(..)
+  , _StatusCodeException
+  , _InvalidUrlException
+  , _TooManyRedirects
+  , _UnparseableRedirect
+  , _TooManyRetries
+  , _HttpParserException
+  , _HandshakeFailed
+  , _OverlongHeaders
+  , _ResponseTimeout
+  , _FailedConnectionException
+  , _ExpectedBlankAfter100Continue
+  , _InvalidStatusLine
+  , _InvalidHeader
+  , _InternalIOException
+  , _ProxyConnectException
+  , _NoResponseDataReceived
+  , _TlsException
+  , _TlsNotSupported
+  , _ResponseBodyTooShort
+  , _InvalidChunkHeaders
+  , _IncompleteHeaders
+  ) where
+
+import           Control.Applicative
+import           Control.Exception (SomeException, IOException)
+import           Control.Exception.Lens (exception)
+import           Control.Lens
+import           Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as Lazy
+import           Data.Word (Word64)
+import qualified Network.HTTP.Conduit as H
+import qualified Network.HTTP.Client.Internal as H
+import qualified Network.HTTP.Types as H
+import           Network.Socket (HostAddress)
+
+
+-- | 'H.method' lens
+method :: Lens' H.Request H.Method
+method f req = f (H.method req) <&> \m' -> req { H.method = m' }
+{-# INLINE method #-}
+
+-- | 'H.secure' lens
+secure :: Lens' H.Request Bool
+secure f req = f (H.secure req) <&> \s' -> req { H.secure = s' }
+{-# INLINE secure #-}
+
+-- | 'H.host' lens
+host :: Lens' H.Request ByteString
+host f req = f (H.host req) <&> \h' -> req { H.host = h' }
+{-# INLINE host #-}
+
+-- | 'H.port' lens
+port :: Lens' H.Request Int
+port f req = f (H.port req) <&> \p' -> req { H.port = p' }
+{-# INLINE port #-}
+
+-- | 'H.path' lens
+path :: Lens' H.Request ByteString
+path f req = f (H.path req) <&> \p' -> req { H.path = p' }
+{-# INLINE path #-}
+
+-- | 'H.queryString' lens
+queryString :: Lens' H.Request ByteString
+queryString f req = f (H.queryString req) <&> \qs' -> req { H.queryString = qs' }
+{-# INLINE queryString #-}
+
+-- | 'H.requestBody' lens
+requestBody :: Lens' H.Request H.RequestBody
+requestBody f req = f (H.requestBody req) <&> \rb' -> req { H.requestBody = rb' }
+{-# INLINE requestBody #-}
+
+-- | 'H.requestHeaders' lens
+requestHeaders :: Lens' H.Request H.RequestHeaders
+requestHeaders f req = f (H.requestHeaders req) <&> \rh' -> req { H.requestHeaders = rh' }
+{-# INLINE requestHeaders #-}
+
+-- | 'H.proxy'
+proxy :: Lens' H.Request (Maybe H.Proxy)
+proxy f req = f (H.proxy req) <&> \mp' -> req { H.proxy = mp' }
+{-# INLINE proxy #-}
+
+-- | 'H.hostAddress'
+hostAddress :: Lens' H.Request (Maybe HostAddress)
+hostAddress f req = f (H.hostAddress req) <&> \ha' -> req { H.hostAddress = ha' }
+{-# INLINE hostAddress #-}
+
+-- | 'H.rawBody'
+rawBody :: Lens' H.Request Bool
+rawBody f req = f (H.rawBody req) <&> \b' -> req { H.rawBody = b' }
+{-# INLINE rawBody #-}
+
+-- | 'H.decompress'
+decompress :: Lens' H.Request (ByteString -> Bool)
+decompress f req = f (H.decompress req) <&> \btb' -> req { H.decompress = btb' }
+{-# INLINE decompress #-}
+
+-- | 'H.redirectCount' lens
+redirectCount :: Lens' H.Request Int
+redirectCount f req = f (H.redirectCount req) <&> \rc' -> req { H.redirectCount = rc' }
+{-# INLINE redirectCount #-}
+
+-- | 'H.checkStatus' lens
+checkStatus :: Lens' H.Request (H.Status -> H.ResponseHeaders -> H.CookieJar -> Maybe SomeException)
+checkStatus f req = f (H.checkStatus req) <&> \cs' -> req { H.checkStatus = cs' }
+{-# INLINE checkStatus #-}
+
+-- | 'H.responseTimeout' lens
+responseTimeout :: Lens' H.Request (Maybe Int)
+responseTimeout f req = f (H.responseTimeout req) <&> \rt' -> req { H.responseTimeout = rt' }
+{-# INLINE responseTimeout #-}
+
+-- | 'H.cookieJar'
+cookieJar :: Lens' H.Request (Maybe H.CookieJar)
+cookieJar f req = f (H.cookieJar req) <&> \mcj' -> req { H.cookieJar = mcj' }
+{-# INLINE cookieJar #-}
+
+-- | 'H.getConnectionWrapper'
+getConnectionWrapper
+  :: Lens' H.Request
+      ( Maybe Int
+      -> H.HttpException
+      -> IO (H.ConnRelease, H.Connection, H.ManagedConn)
+      -> IO (Maybe Int, (H.ConnRelease, H.Connection, H.ManagedConn))
+      )
+getConnectionWrapper f req =
+  f (H.getConnectionWrapper req) <&> \wat' -> req { H.getConnectionWrapper = wat' }
+{-# INLINE getConnectionWrapper #-}
+
+
+-- | @http-conduit@ exceptions
+class AsHttpException p f t where
+  -- | @http-conduit@ exceptions overloading
+  _HttpException :: Overloaded' p f t H.HttpException
+
+instance AsHttpException p f H.HttpException where
+  _HttpException = id
+  {-# INLINE _HttpException #-}
+
+instance (Choice p, Applicative f) => AsHttpException p f SomeException where
+  _HttpException = exception
+  {-# INLINE _HttpException #-}
+
+-- | 'H.StatusCodeException' exception
+_StatusCodeException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (H.Status, H.ResponseHeaders, H.CookieJar)
+_StatusCodeException = _HttpException . prism' (uncurry3 H.StatusCodeException) go where
+  go (H.StatusCodeException s rh cj) = Just (s, rh, cj)
+  go _ = Nothing
+{-# INLINE _StatusCodeException #-}
+
+-- | 'H.InvalidUrlException' exception
+_InvalidUrlException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (String, String)
+_InvalidUrlException = _HttpException . prism' (uncurry H.InvalidUrlException) go where
+  go (H.InvalidUrlException s s') = Just (s, s')
+  go _ = Nothing
+{-# INLINE _InvalidUrlException #-}
+
+-- | 'H.TooManyRedirects' exception
+_TooManyRedirects
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t [H.Response Lazy.ByteString]
+_TooManyRedirects = _HttpException . prism' H.TooManyRedirects go where
+  go (H.TooManyRedirects rs) = Just rs
+  go _ = Nothing
+{-# INLINE _TooManyRedirects #-}
+
+-- | 'H.UnparseableRedirect' exception
+_UnparseableRedirect
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (H.Response Lazy.ByteString)
+_UnparseableRedirect = _HttpException . prism' H.UnparseableRedirect go where
+  go (H.UnparseableRedirect r) = Just r
+  go _ = Nothing
+{-# INLINE _UnparseableRedirect #-}
+
+-- | 'H.TooManyRetries' exception
+_TooManyRetries
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_TooManyRetries = _HttpException . prism' (const H.TooManyRetries) go where
+  go H.TooManyRetries = Just ()
+  go _ = Nothing
+{-# INLINE _TooManyRetries #-}
+
+-- | 'H.HttpParserException' exception
+_HttpParserException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t String
+_HttpParserException = _HttpException . prism' H.HttpParserException go where
+  go (H.HttpParserException s) = Just s
+  go _ = Nothing
+{-# INLINE _HttpParserException #-}
+
+-- | 'H.HandshakeFailed' exception
+_HandshakeFailed
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_HandshakeFailed = _HttpException . prism' (const H.HandshakeFailed) go where
+  go H.HandshakeFailed = Just ()
+  go _ = Nothing
+{-# INLINE _HandshakeFailed #-}
+
+-- | 'H.OverlongHeaders' exception
+_OverlongHeaders
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_OverlongHeaders = _HttpException . prism' (const H.OverlongHeaders) go where
+  go H.OverlongHeaders = Just ()
+  go _ = Nothing
+{-# INLINE _OverlongHeaders #-}
+
+-- | 'H.ResponseTimeout' exception
+_ResponseTimeout
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_ResponseTimeout = _HttpException . prism' (const H.ResponseTimeout) go where
+  go H.ResponseTimeout = Just ()
+  go _ = Nothing
+{-# INLINE _ResponseTimeout #-}
+
+-- | 'H.FailedConnectionException' exception
+_FailedConnectionException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (String, Int)
+_FailedConnectionException = _HttpException . prism' (uncurry H.FailedConnectionException) go where
+  go (H.FailedConnectionException s i) = Just (s, i)
+  go _ = Nothing
+{-# INLINE _FailedConnectionException #-}
+
+-- | 'H.ExpectedBlankAfter100Continue' exception
+_ExpectedBlankAfter100Continue
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_ExpectedBlankAfter100Continue =
+  _HttpException . prism' (const H.ExpectedBlankAfter100Continue) go where
+    go H.ExpectedBlankAfter100Continue = Just ()
+    go _ = Nothing
+{-# INLINE _ExpectedBlankAfter100Continue #-}
+
+-- | 'H.InvalidStatusLine' exception
+_InvalidStatusLine
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ByteString
+_InvalidStatusLine = _HttpException . prism' H.InvalidStatusLine go where
+  go (H.InvalidStatusLine b) = Just b
+  go _ = Nothing
+{-# INLINE _InvalidStatusLine #-}
+
+-- | 'H.InvalidHeader' exception
+_InvalidHeader
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ByteString
+_InvalidHeader = _HttpException . prism' H.InvalidHeader go where
+  go (H.InvalidHeader b) = Just b
+  go _ = Nothing
+{-# INLINE _InvalidHeader #-}
+
+-- | 'H.InternalIOException' exception
+_InternalIOException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t IOException
+_InternalIOException = _HttpException . prism' H.InternalIOException go where
+  go (H.InternalIOException ioe) = Just ioe
+  go _ = Nothing
+{-# INLINE _InternalIOException #-}
+
+-- | 'H.ProxyConnectException' exception
+_ProxyConnectException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (ByteString, Int, Either ByteString H.HttpException)
+_ProxyConnectException = _HttpException . prism' (uncurry3 H.ProxyConnectException) go where
+  go (H.ProxyConnectException b i ebhe) = Just (b, i, ebhe)
+  go _ = Nothing
+{-# INLINE _ProxyConnectException #-}
+
+-- | 'H.NoResponseDataReceived' exception
+_NoResponseDataReceived
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_NoResponseDataReceived = _HttpException . prism' (const H.NoResponseDataReceived) go where
+  go H.NoResponseDataReceived = Just ()
+  go _ = Nothing
+{-# INLINE _NoResponseDataReceived #-}
+
+-- | 'H.TlsException' exception
+_TlsException
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t SomeException
+_TlsException = _HttpException . prism' H.TlsException go where
+  go (H.TlsException se) = Just se
+  go _ = Nothing
+{-# INLINE _TlsException #-}
+
+-- | 'H.TlsNotSupported' exception
+_TlsNotSupported
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_TlsNotSupported = _HttpException . prism' (const H.TlsNotSupported) go where
+  go H.TlsNotSupported = Just ()
+  go _ = Nothing
+{-# INLINE _TlsNotSupported #-}
+
+-- | 'H.ResponseBodyTooShort' exception
+_ResponseBodyTooShort
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t (Word64, Word64)
+_ResponseBodyTooShort = _HttpException . prism' (uncurry H.ResponseBodyTooShort) go where
+  go (H.ResponseBodyTooShort w w') = Just (w, w')
+  go _ = Nothing
+{-# INLINE _ResponseBodyTooShort #-}
+
+-- | 'H.InvalidChunkHeaders' exception
+_InvalidChunkHeaders
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_InvalidChunkHeaders = _HttpException . prism' (const H.InvalidChunkHeaders) go where
+  go H.InvalidChunkHeaders = Just ()
+  go _ = Nothing
+{-# INLINE _InvalidChunkHeaders #-}
+
+-- | 'H.IncompleteHeaders' exception
+_IncompleteHeaders
+  :: (AsHttpException p f t, Choice p, Applicative f)
+  => Overloaded' p f t ()
+_IncompleteHeaders = _HttpException . prism' (const H.IncompleteHeaders) go where
+  go H.IncompleteHeaders = Just ()
+  go _ = Nothing
+{-# INLINE _IncompleteHeaders #-}
+
+uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d
+uncurry3 f (a, b, c) = f a b c
+{-# INLINE uncurry3 #-}
diff --git a/test/Doctest.hs b/test/Doctest.hs
new file mode 100644
--- /dev/null
+++ b/test/Doctest.hs
@@ -0,0 +1,28 @@
+module Main where
+
+import Control.Applicative ((<$>))
+import Control.Monad (forM, liftM)
+import Data.List (isSuffixOf)
+import System.Directory (getDirectoryContents, doesDirectoryExist)
+import System.FilePath ((</>))
+import Test.DocTest (doctest)
+
+
+main :: IO ()
+main = sources "src" >>= doctest
+
+sources :: FilePath -> IO [FilePath]
+sources root = do
+  files <- contents root
+  liftM concat . forM files $ \file -> do
+    let path = root </> file
+    is_dir <- doesDirectoryExist path
+    case is_dir of
+      True              -> sources path
+      _ | isSource path -> return [path]
+        | otherwise     -> return []
+ where
+  isSource path = any (`isSuffixOf` path) [".hs", ".lhs"]
+
+contents :: FilePath -> IO [FilePath]
+contents dir = filter (`notElem` [".", ".."]) <$> getDirectoryContents dir
diff --git a/test/Jenkins/RestSpec.hs b/test/Jenkins/RestSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Jenkins/RestSpec.hs
@@ -0,0 +1,98 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Jenkins.RestSpec (spec) where
+
+import           Control.Applicative
+import           Control.Monad.Trans.State (State, evalState, get, put)
+import qualified Data.ByteString as Strict
+import qualified Data.ByteString.Lazy as Lazy
+import           Data.Monoid (mempty)
+import           Test.Hspec
+import qualified Jenkins.Rest as Rest
+import           Jenkins.Rest.Internal
+
+
+spec :: Spec
+spec = do
+  context "POST requests" $ do
+    it "post_ sends POST request with empty body" $ do
+      interpret $ do
+        Rest.post_ "foo"
+        Rest.post_ "bar"
+        Rest.post_ "baz"
+     `shouldBe`
+      [QPost 0 "" "foo", QPost 1 "" "bar", QPost 2 "" "baz"]
+
+    it "post sends POST request with non-empty body" $ do
+      interpret $ do
+        Rest.post "foo" "qux"
+        Rest.post "bar" "quux"
+        Rest.post "baz" "xyzzy"
+     `shouldBe`
+      [QPost 0 "qux" "foo", QPost 1 "quux" "bar", QPost 2 "xyzzy" "baz"]
+
+  context "GET requests" $
+    it "get sends GET requests" $ do
+      interpret $ do
+        Rest.get "foo"
+        Rest.get "bar"
+        Rest.get "baz"
+     `shouldBe`
+      [QGet 0 "foo", QGet 1 "bar", QGet 2 "baz"]
+
+
+  describe "reload" $
+    it "calls $jenkins_url/reload with POST query and then disconnects" $ do
+      interpret $ do
+        Rest.reload
+        Rest.post_ "foo"
+     `shouldBe`
+      [QPost 0 "" "reload", QDisconnect]
+
+  describe "restart" $
+    it "calls $jenkins_url/safeRestart with POST query and then disconnects" $ do
+      interpret $ do
+        Rest.restart
+        Rest.post_ "bar"
+     `shouldBe`
+      [QPost 0 "" "safeRestart", QDisconnect]
+
+  describe "forceRestart" $
+    it "calls $jenkins_url/restart with POST query and then disconnects" $ do
+      interpret $ do
+        Rest.forceRestart
+        Rest.post_ "baz"
+     `shouldBe`
+      [QPost 0 "" "restart", QDisconnect]
+
+
+data Query =
+    QGet Int Strict.ByteString
+  | QPost Int Lazy.ByteString Strict.ByteString
+  | QDisconnect
+    deriving (Show, Eq)
+
+newtype Requests a = Requests [a]
+  deriving (Show, Eq)
+
+interpret :: Rest.Jenkins a -> [Query]
+interpret adt = evalState (iterJenkins go ([] <$ adt)) (Requests [0..]) where
+  go :: JenkinsF (State (Requests Int) [Query]) -> State (Requests Int) [Query]
+  go (Get m n) = do
+    r <- render QGet m
+    fmap (r :) (n mempty)
+  go (Post m body n) = do
+    r <- render (\x y -> QPost x body y) m
+    fmap (r :) (n mempty)
+  go Dcon =
+    return [QDisconnect]
+
+render :: (a -> Strict.ByteString -> Query) -> Rest.Method f x -> State (Requests a) Query
+render f m = do
+  n <- next
+  return $ f n (Rest.render m)
+
+next :: State (Requests a) a
+next = do
+  Requests (x:xs) <- get
+  put (Requests xs)
+  return x
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/tests/Doctests.hs b/tests/Doctests.hs
deleted file mode 100644
--- a/tests/Doctests.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-module Main where
-
-import Control.Applicative ((<$>))
-import Control.Monad (forM, liftM)
-import Data.List (isSuffixOf)
-import System.Directory (getDirectoryContents, doesDirectoryExist)
-import System.FilePath ((</>))
-import Test.DocTest (doctest)
-
-
-main :: IO ()
-main = sources "src" >>= doctest
-
-sources :: FilePath -> IO [FilePath]
-sources root = do
-  files <- contents root
-  liftM concat . forM files $ \file -> do
-    let path = root </> file
-    is_dir <- doesDirectoryExist path
-    case is_dir of
-      True              -> sources path
-      _ | isSource path -> return [path]
-        | otherwise     -> return []
- where
-  isSource path = any (`isSuffixOf` path) [".hs", ".lhs"]
-
-contents :: FilePath -> IO [FilePath]
-contents dir = filter (`notElem` [".", ".."]) <$> getDirectoryContents dir
