diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,8 @@
+## Upcoming
+
+## 1.0.0
+
+Initial release:
+
+* Implement `queryGitHub` and `GHEndpoint`
+* Implement `GitHubT` and `MonadGitHubREST`
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,11 @@
+Copyright 2019 LeapYear
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# github-rest
+
+![CircleCI](https://img.shields.io/circleci/build/github/LeapYear/github-rest)
+![Hackage](https://img.shields.io/hackage/v/github-rest)
+
+A package providing a more flexible interface to accessing the [GitHub API](https://developer.github.com/v3/).
+Endpoints are created using the `GHEndpoint` constructor and are executed with
+the `queryGitHub` function in the `GitHubT` monad.
+
+## Quickstart
+
+This quickstart will demonstrate querying endpoints in a hypothetical public
+GitHub repo `alice/my-project`.
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-type-defaults #-}
+
+import Data.Text (Text)
+import GitHub.REST
+import Network.HTTP.Types (StdMethod(..))
+
+default (Text)
+
+main = do
+  let state = GitHubState
+        { token = Nothing
+          -- ^ An authentication token to use, if any.
+        , userAgent = "alice/my-project"
+          -- ^ GitHub requires this to be set to a User Agent specific to your
+          -- application: https://developer.github.com/v3/#user-agent-required
+        , apiVersion = "v3"
+          -- ^ Specifies the API version to query: https://developer.github.com/v3/media/
+        }
+
+  runGitHubT state $ do
+
+    -- Get information for the "master" branch
+    -- https://developer.github.com/v3/git/refs/#get-a-single-reference
+    ref <- queryGitHub GHEndpoint
+      { method = GET
+        -- Colon-prefixed components in the endpoint will be interpolated by
+        -- the values in 'endpointVals'.
+        -- In this case, "/repos/alice/my-project/git/refs/heads/master"
+      , endpoint = "/repos/:owner/:repo/git/refs/:ref"
+      , endpointVals =
+        [ "owner" := "alice"
+        , "repo" := "my-project"
+        , "ref" := "heads/master"
+        ]
+      , ghData = []
+      }
+
+    -- 'github-rest' provides a '.:' helper for when the API guarantees that a
+    -- key in a JSON object exists
+    --
+    -- The result of 'queryGitHub' is anything that's an instance of FromJSON,
+    -- if using manually-defined data types is preferred over using '.:'. This
+    -- package can be easily used with the aeson-schemas library, which
+    -- provides a type-safe way to query JSON data.
+    let sha :: Text
+        sha = ref .: "object" .: "sha"
+
+    -- Create a new branch called "foo"
+    -- https://developer.github.com/v3/git/refs/#create-a-reference
+    queryGitHub GHEndpoint
+      { method = POST
+      , endpoint = "/repos/:owner/:repo/git/refs"
+      , endpointVals =
+        [ "owner" := "alice"
+        , "repo" := "my-project"
+        ]
+      , ghData =
+        [ "ref" := "refs/heads/foo"
+        , "sha" := sha
+        ]
+      }
+```
+
+## Comparison to other libraries
+
+The `github` package provides a decent API for querying the GitHub API,
+and it defines Haskell data types for each endpoint. These data types can
+be used as the result of `queryGitHub`.
+
+This package provides a different interface for people with different tastes:
+
+* `github-rest` informs the user exactly which GitHub endpoint is being hit
+  (e.g. `/repos/:owner/:repo`). Users no longer need to spend time trying to
+  scour documentation to find the corresponding function for an endpoint.
+
+* `github-rest` passes authentication once, with requests executed in a single
+  monadic context. The `github` package requires passing in an authentication
+  token every time a request is executed
+
+* In the same vein, `github-rest` provides a monad transformer that handles all
+  GitHub state needed to execute `queryGitHub`. `github` runs everything in
+  `IO`, expecting the caller to keep track of GitHub state manually.
+
+* `github-rest` allows usage with [`aeson-schemas`](http://hackage.haskell.org/package/aeson-schemas)
diff --git a/github-rest.cabal b/github-rest.cabal
new file mode 100644
--- /dev/null
+++ b/github-rest.cabal
@@ -0,0 +1,104 @@
+cabal-version: >= 1.10
+
+-- This file has been generated from package.yaml by hpack version 0.31.1.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: a5d2feb7a43fffeb1a008e74c6eace7ea1b6e11097e1e9f10a0e23056bc308d7
+
+name:           github-rest
+version:        1.0.0
+synopsis:       Query the GitHub REST API programmatically
+description:    Query the GitHub REST API programmatically, which can provide a more
+                flexible and clear interface than if all of the endpoints and their types
+                were defined as Haskell values.
+category:       GitHub
+homepage:       https://github.com/LeapYear/github-rest#readme
+bug-reports:    https://github.com/LeapYear/github-rest/issues
+author:         Brandon Chinn <brandon@leapyear.io>
+maintainer:     Brandon Chinn <brandon@leapyear.io>
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/LeapYear/github-rest
+
+library
+  exposed-modules:
+      GitHub.REST
+      GitHub.REST.Auth
+      GitHub.REST.Endpoint
+      GitHub.REST.KeyValue
+      GitHub.REST.Monad
+      GitHub.REST.Monad.Class
+      GitHub.REST.PageLinks
+  other-modules:
+      Paths_github_rest
+  hs-source-dirs:
+      src
+  ghc-options: -Wall
+  build-depends:
+      aeson >=1.1.2.0 && <1.5
+    , base >=4.9 && <5
+    , bytestring >=0.10.8.1 && <0.11
+    , http-client >=0.5.13.1 && <0.7
+    , http-client-tls >=0.3.5.3 && <0.4
+    , http-types >=0.12.1 && <0.13
+    , jwt >=0.9.0 && <0.11
+    , mtl >=2.2.2 && <2.3
+    , scientific >=0.3.6.2 && <0.4
+    , text >=1.2.2.2 && <1.3
+    , time >=1.8.0.2 && <1.10
+    , transformers >=0.5.2.0 && <0.6
+    , unliftio >=0.2.7.0 && <0.3
+    , unliftio-core >=0.1.1.0 && <0.2
+  if impl(ghc >= 8.0)
+    ghc-options: -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wnoncanonical-monad-instances
+  if impl(ghc < 8.8)
+    ghc-options: -Wnoncanonical-monadfail-instances
+  default-language: Haskell2010
+
+test-suite github-rest-test
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules:
+      Endpoint
+      Helpers
+      MockQuery
+      PageLinks
+      Query
+      Paths_github_rest
+  hs-source-dirs:
+      test
+  ghc-options: -Wall
+  build-depends:
+      aeson >=1.1.2.0 && <1.5
+    , aeson-qq
+    , base >=4.9 && <5
+    , bytestring >=0.10.8.1 && <0.11
+    , github-rest
+    , http-client >=0.5.13.1 && <0.7
+    , http-client-tls >=0.3.5.3 && <0.4
+    , http-types >=0.12.1 && <0.13
+    , jwt >=0.9.0 && <0.11
+    , mtl >=2.2.2 && <2.3
+    , scientific >=0.3.6.2 && <0.4
+    , tasty
+    , tasty-golden
+    , tasty-hunit
+    , tasty-quickcheck
+    , text >=1.2.2.2 && <1.3
+    , time >=1.8.0.2 && <1.10
+    , transformers >=0.5.2.0 && <0.6
+    , unliftio >=0.2.7.0 && <0.3
+    , unliftio-core >=0.1.1.0 && <0.2
+  if impl(ghc >= 8.0)
+    ghc-options: -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wnoncanonical-monad-instances
+  if impl(ghc < 8.8)
+    ghc-options: -Wnoncanonical-monadfail-instances
+  default-language: Haskell2010
diff --git a/src/GitHub/REST.hs b/src/GitHub/REST.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST.hs
@@ -0,0 +1,73 @@
+{-|
+Module      :  GitHub.REST
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Definitions for querying the GitHub REST API. See README.md for an example.
+-}
+
+module GitHub.REST
+  (
+  -- * Monad transformer and type-class for querying the GitHub REST API
+    MonadGitHubREST(..)
+  , GitHubT
+  , GitHubState(..)
+  , runGitHubT
+  -- * GitHub authentication
+  , Token(..)
+  -- * GitHub Endpoints
+  , GHEndpoint(..)
+  , GitHubData
+  , EndpointVals
+  -- * KeyValue pairs
+  , KeyValue(..)
+  -- * Helpers
+  , githubTry
+  , githubTry'
+  , (.:)
+  -- * Re-exports
+  , StdMethod(..)
+  ) where
+
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Data.Aeson (FromJSON, Value(..), decode, withObject)
+import Data.Aeson.Types (parseEither, parseField)
+import qualified Data.ByteString.Lazy as ByteStringL
+import Data.Text (Text)
+import Network.HTTP.Client
+    (HttpException(..), HttpExceptionContent(..), Response(..))
+import Network.HTTP.Types (Status, StdMethod(..), status422)
+import UnliftIO.Exception (handleJust)
+
+import GitHub.REST.Auth
+import GitHub.REST.Endpoint
+import GitHub.REST.KeyValue
+import GitHub.REST.Monad
+
+{- HTTP exception handling -}
+
+-- | Handle 422 exceptions thrown by the GitHub REST API.
+--
+-- Most client errors are 422, since we should always be sending valid JSON. If an endpoint
+-- throws different error codes, use githubTry'.
+--
+-- https://developer.github.com/v3/#client-errors
+githubTry :: MonadUnliftIO m => m a -> m (Either Value a)
+githubTry = githubTry' status422
+
+-- | Handle the given exception thrown by the GitHub REST API.
+githubTry' :: MonadUnliftIO m => Status -> m a -> m (Either Value a)
+githubTry' status = handleJust statusException (return . Left) . fmap Right
+  where
+    statusException (HttpExceptionRequest _ (StatusCodeException r body))
+      | responseStatus r == status = decode $ ByteStringL.fromStrict body
+    statusException _ = Nothing
+
+{- Aeson helpers -}
+
+-- | Get the given key from the Value, erroring if it doesn't exist.
+(.:) :: FromJSON a => Value -> Text -> a
+(.:) v key = either error id $ parseEither parseObject v
+  where
+    parseObject = withObject "parseObject" (`parseField` key)
diff --git a/src/GitHub/REST/Auth.hs b/src/GitHub/REST/Auth.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/Auth.hs
@@ -0,0 +1,73 @@
+{-|
+Module      :  GitHub.REST.Auth
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Definitions for handling authentication with the GitHub REST API.
+-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module GitHub.REST.Auth
+  ( Token(..)
+  , fromToken
+  -- * Helpers for using JWT tokens with the GitHub API
+  , getJWTToken
+  , loadSigner
+  ) where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as ByteString
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+#endif
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Data.Time (addUTCTime, getCurrentTime)
+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
+import qualified Web.JWT as JWT
+
+-- | The token to use to authenticate with GitHub.
+data Token
+  = AccessToken ByteString
+    -- ^ https://developer.github.com/v3/#authentication
+  | BearerToken ByteString
+    -- ^ https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
+  deriving (Show)
+
+fromToken :: Token -> ByteString
+fromToken = \case
+  AccessToken t -> "token " <> t
+  BearerToken t -> "bearer " <> t
+
+-- | The ID of your GitHub application
+type AppId = Int
+
+-- | Create a JWT token that expires in 10 minutes.
+getJWTToken :: JWT.Signer -> AppId -> IO Token
+getJWTToken signer appId = mkToken <$> getNow
+  where
+#if MIN_VERSION_jwt(0,10,0)
+    signToken = flip JWT.encodeSigned mempty
+#else
+    signToken = JWT.encodeSigned
+#endif
+    mkToken now =
+      let claims = mempty
+            { JWT.iat = JWT.numericDate $ utcTimeToPOSIXSeconds now
+            , JWT.exp = JWT.numericDate $ utcTimeToPOSIXSeconds now + (10 * 60)
+            , JWT.iss = JWT.stringOrURI $ Text.pack $ show appId
+            }
+      in BearerToken . Text.encodeUtf8 $ signToken signer claims
+    -- lose a second in the case of rounding
+    -- https://github.community/t5/GitHub-API-Development-and/quot-Expiration-time-claim-exp-is-too-far-in-the-future-quot/m-p/20457/highlight/true#M1127
+    getNow = addUTCTime (-1) <$> getCurrentTime
+
+-- | Load a RSA private key as a Signer from the given file path.
+loadSigner :: FilePath -> IO JWT.Signer
+loadSigner file = maybe badSigner return . readSigner =<< ByteString.readFile file
+  where
+    badSigner = fail $ "Not a valid RSA private key file: " ++ file
+    readSigner = fmap JWT.RSAPrivateKey . JWT.readRsaSecret
diff --git a/src/GitHub/REST/Endpoint.hs b/src/GitHub/REST/Endpoint.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/Endpoint.hs
@@ -0,0 +1,62 @@
+{-|
+Module      :  GitHub.REST.Endpoint
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Define the 'GHEndpoint' helper type for defining a call to a GitHub API endpoint.
+-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module GitHub.REST.Endpoint
+  ( GHEndpoint(..)
+  , EndpointVals
+  , GitHubData
+  , endpointPath
+  , renderMethod
+  ) where
+
+import Data.Maybe (fromMaybe)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+#endif
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.HTTP.Types (Method, StdMethod, renderStdMethod)
+
+import GitHub.REST.KeyValue (KeyValue, kvToText)
+
+type EndpointVals = [KeyValue]
+type GitHubData = [KeyValue]
+
+-- | A call to a GitHub API endpoint.
+data GHEndpoint = GHEndpoint
+  { method       :: StdMethod
+  , endpoint     :: Text
+    -- ^ The GitHub API endpoint, with colon-prefixed components that will be replaced; e.g.
+    -- @"\/users\/:username\/repos"@
+  , endpointVals :: EndpointVals
+    -- ^ Key-value pairs to replace colon-prefixed components in 'endpoint'; e.g.
+    -- @[ "username" := ("alice" :: Text) ]@
+  , ghData       :: GitHubData
+    -- ^ Key-value pairs to send in the request body; e.g.
+    -- @[ "sort" := ("created" :: Text), "direction" := ("asc" :: Text) ]@
+  }
+
+-- | Return the endpoint path, populated by the values in 'endpointVals'.
+endpointPath :: GHEndpoint -> Text
+endpointPath GHEndpoint{..} = Text.intercalate "/" . map populate . Text.splitOn "/" $ endpoint
+  where
+    values = map kvToText endpointVals
+    populate t = case Text.uncons t of
+      Just (':', key) -> fromMaybe
+        (fail' $ "Could not find value for key '" <> key <> "'")
+        $ lookup key values
+      _ -> t
+    fail' msg = error . Text.unpack $ msg <> ": " <> endpoint
+
+-- | Render the method of the endpoint.
+renderMethod :: GHEndpoint -> Method
+renderMethod = renderStdMethod . method
diff --git a/src/GitHub/REST/KeyValue.hs b/src/GitHub/REST/KeyValue.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/KeyValue.hs
@@ -0,0 +1,52 @@
+{-|
+Module      :  GitHub.REST.KeyValue
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Define the 'KeyValue' helper type.
+-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GADTs #-}
+
+module GitHub.REST.KeyValue
+  ( KeyValue(..)
+  , kvToValue
+  , kvToText
+  ) where
+
+import Data.Aeson (ToJSON(..), Value(..), object)
+import Data.Aeson.Types (Pair)
+import Data.Scientific (floatingOrInteger)
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+-- | A type representing a key-value pair.
+data KeyValue where
+  (:=) :: (Show v, ToJSON v) => Text -> v -> KeyValue
+infixr 1 :=
+
+instance Show KeyValue where
+  show = show . kvToText
+
+instance {-# OVERLAPS #-} ToJSON [KeyValue] where
+  toJSON = kvToValue
+
+-- | Convert a 'KeyValue' into a 'Pair'.
+toPair :: KeyValue -> Pair
+toPair (k := v) = (k, toJSON v)
+
+-- | Convert the given KeyValues into a JSON Object.
+kvToValue :: [KeyValue] -> Value
+kvToValue = object . map toPair
+
+-- | Represent the given KeyValue as a pair of Texts.
+kvToText :: KeyValue -> (Text, Text)
+kvToText (k := v) = (k, v')
+  where
+    v' = case toJSON v of
+      String t -> t
+      Number x -> Text.pack . prettyNum $ x
+      Bool b -> Text.pack . show $ b
+      _ -> error $ "Could not convert value: " ++ show v
+    prettyNum x = either show show (floatingOrInteger x :: Either Double Integer)
diff --git a/src/GitHub/REST/Monad.hs b/src/GitHub/REST/Monad.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/Monad.hs
@@ -0,0 +1,125 @@
+{-|
+Module      :  GitHub.REST.Monad
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Defines 'GitHubT' and 'MonadGitHubREST', a monad transformer and type class that gives a monad @m@
+the capability to query the GitHub REST API.
+-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
+
+module GitHub.REST.Monad
+  ( MonadGitHubREST(..)
+  , GitHubT
+  , GitHubState(..)
+  , runGitHubT
+  ) where
+
+#if !MIN_VERSION_base(4,13,0)
+import Control.Monad.Fail (MonadFail)
+#endif
+import Control.Monad.IO.Class (MonadIO(..))
+import Control.Monad.IO.Unlift (MonadUnliftIO(..), UnliftIO(..), withUnliftIO)
+import Control.Monad.Reader (ReaderT, ask, runReaderT)
+import Control.Monad.Trans (MonadTrans)
+import Data.Aeson (eitherDecode, encode)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as ByteStringL
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+#endif
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Network.HTTP.Client
+    ( Manager
+    , Request(..)
+    , RequestBody(..)
+    , Response(..)
+    , httpLbs
+    , newManager
+    , parseRequest_
+    , throwErrorStatusCodes
+    )
+import Network.HTTP.Client.TLS (tlsManagerSettings)
+import Network.HTTP.Types (hAccept, hAuthorization, hUserAgent)
+
+import GitHub.REST.Auth (Token, fromToken)
+import GitHub.REST.Endpoint (GHEndpoint(..), endpointPath, renderMethod)
+import GitHub.REST.KeyValue (kvToValue)
+import GitHub.REST.Monad.Class
+import GitHub.REST.PageLinks (parsePageLinks)
+
+data GitHubState = GitHubState
+  { token      :: Maybe Token
+    -- ^ The token to use to authenticate with the API.
+  , userAgent  :: ByteString
+    -- ^ The user agent to use when interacting with the API: https://developer.github.com/v3/#user-agent-required
+  , apiVersion :: ByteString
+    -- ^ The media type will be sent as: application/vnd.github.VERSION+json. For the standard
+    -- API endpoints, "v3" should be sufficient here. See https://developer.github.com/v3/media/
+  }
+
+-- | A simple monad that can run REST calls.
+newtype GitHubT m a = GitHubT
+  { unGitHubT :: ReaderT (Manager, GitHubState) m a
+  }
+  deriving
+    ( Functor
+    , Applicative
+    , Monad
+    , MonadFail
+    , MonadIO
+    , MonadTrans
+    )
+
+instance MonadUnliftIO m => MonadUnliftIO (GitHubT m) where
+  askUnliftIO = GitHubT $
+    withUnliftIO $ \u ->
+      return $ UnliftIO (unliftIO u . unGitHubT)
+
+instance (MonadIO m, MonadFail m) => MonadGitHubREST (GitHubT m) where
+  queryGitHubPage' ghEndpoint = do
+    (manager, GitHubState{..}) <- GitHubT ask
+
+    let request = (parseRequest_ $ Text.unpack $ ghUrl <> endpointPath ghEndpoint)
+          { method = renderMethod ghEndpoint
+          , requestHeaders =
+              [ (hAccept, "application/vnd.github." <> apiVersion <> "+json")
+              , (hUserAgent, userAgent)
+              ] ++ maybe [] ((:[]) . (hAuthorization,) . fromToken) token
+          , requestBody = RequestBodyLBS $ encode $ kvToValue $ ghData ghEndpoint
+          , checkResponse = throwErrorStatusCodes
+          }
+
+    response <- liftIO $ httpLbs request manager
+
+    let body = responseBody response
+        -- empty body always errors when decoding, even if the end user doesn't care about the
+        -- result, like creating a branch, when the endpoint doesn't return anything.
+        --
+        -- In this case, pretend like the server sent back an encoded version of the unit type,
+        -- so that `queryGitHub endpoint` would be typed to `m ()`.
+        nonEmptyBody = if ByteStringL.null body then encode () else body
+        pageLinks = maybe mempty parsePageLinks . lookupHeader "Link" $ response
+
+    return $ case eitherDecode nonEmptyBody of
+      Right payload -> Right (payload, pageLinks)
+      Left e -> Left (Text.pack e, Text.decodeUtf8 $ ByteStringL.toStrict body)
+    where
+      ghUrl = "https://api.github.com"
+      lookupHeader headerName = fmap Text.decodeUtf8 . lookup headerName . responseHeaders
+
+-- | Run the given 'GitHubT' action with the given token and user agent.
+--
+-- The token will be sent with each API request -- see 'Token'. The user agent is also required for
+-- each API request -- see https://developer.github.com/v3/#user-agent-required.
+runGitHubT :: MonadIO m => GitHubState -> GitHubT m a -> m a
+runGitHubT state action = do
+  manager <- liftIO $ newManager tlsManagerSettings
+  (`runReaderT` (manager, state)) . unGitHubT $ action
diff --git a/src/GitHub/REST/Monad/Class.hs b/src/GitHub/REST/Monad/Class.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/Monad/Class.hs
@@ -0,0 +1,140 @@
+{-|
+Module      :  GitHub.REST.Monad.Class
+Maintainer  :  Brandon Chinn <brandon@leapyear.io>
+Stability   :  experimental
+Portability :  portable
+
+Defines 'MonadGitHubREST' that gives a monad @m@ the capability to query the GitHub REST API.
+-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE TypeApplications #-}
+
+module GitHub.REST.Monad.Class
+  ( MonadGitHubREST(..)
+  ) where
+
+import Control.Monad (void, (<=<))
+#if !MIN_VERSION_base(4,13,0)
+import Control.Monad.Fail (MonadFail)
+#endif
+import Control.Monad.Trans.Class (lift)
+import Control.Monad.Trans.Except (ExceptT)
+import Control.Monad.Trans.Identity (IdentityT)
+import Control.Monad.Trans.Maybe (MaybeT)
+import Control.Monad.Trans.Reader (ReaderT)
+import qualified Control.Monad.Trans.RWS.Lazy as Lazy
+import qualified Control.Monad.Trans.RWS.Strict as Strict
+import qualified Control.Monad.Trans.State.Lazy as Lazy
+import qualified Control.Monad.Trans.State.Strict as Strict
+import qualified Control.Monad.Trans.Writer.Lazy as Lazy
+import qualified Control.Monad.Trans.Writer.Strict as Strict
+import Data.Aeson (FromJSON, Value)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+#endif
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import GitHub.REST.Endpoint
+import GitHub.REST.PageLinks (PageLinks(..))
+
+-- | A type class for monads that can query the GitHub REST API.
+--
+-- Example:
+--
+-- > -- create the "foo" branch
+-- > queryGitHub GHEndpoint
+-- >   { method = POST
+-- >   , endpoint = "/repos/:owner/:repo/git/refs"
+-- >   , endpointVals =
+-- >     [ "owner" := "alice"
+-- >     , "repo" := "my-project"
+-- >     ]
+-- >   , ghData =
+-- >     [ "ref" := "refs/heads/foo"
+-- >     , "sha" := "1234567890abcdef"
+-- >     ]
+-- >   }
+--
+-- It's recommended that you create functions for the API endpoints you're using:
+--
+-- > deleteBranch branch = queryGitHub GHEndpoint
+-- >   { method = DELETE
+-- >   , endpoint = "/repos/:owner/:repo/git/refs/:ref"
+-- >   , endpointVals =
+-- >     [ "owner" := "alice"
+-- >     , "repo" := "my-project"
+-- >     , "ref" := "heads/" <> branch
+-- >     ]
+-- >   , ghData = []
+-- >   }
+class MonadFail m => MonadGitHubREST m where
+  {-# MINIMAL queryGitHubPage' #-}
+
+  -- | Query GitHub, returning @Right (payload, links)@ if successful, where @payload@ is the
+  -- response that GitHub sent back and @links@ containing any pagination links GitHub may have
+  -- sent back. If the response could not be decoded as JSON, returns
+  -- @Left (error message, response from server)@.
+  --
+  -- Errors on network connection failures or if GitHub sent back an error message. Use `githubTry`
+  -- if you wish to handle GitHub errors.
+  queryGitHubPage' :: FromJSON a => GHEndpoint -> m (Either (Text, Text) (a, PageLinks))
+
+  -- | 'queryGitHubPage'', except calls 'fail' if JSON decoding fails.
+  queryGitHubPage :: FromJSON a => GHEndpoint -> m (a, PageLinks)
+  queryGitHubPage = either fail' pure <=< queryGitHubPage'
+    where
+      fail' (message, response) =
+        let ellipses s = if Text.length s > 100 then take 100 (Text.unpack s) ++ "..." else Text.unpack s
+        in fail $ "Could not decode response:\nmessage = " ++ ellipses message ++ "\nresponse = " ++ ellipses response
+
+  -- | 'queryGitHubPage', except ignoring pagination links.
+  queryGitHub :: FromJSON a => GHEndpoint -> m a
+  queryGitHub = fmap fst . queryGitHubPage
+
+  -- | Repeatedly calls 'queryGitHubPage' for each page returned by GitHub and concatenates the
+  -- results.
+  queryGitHubAll :: (FromJSON a, Monoid a) => GHEndpoint -> m a
+  queryGitHubAll ghEndpoint = do
+    (payload, pageLinks) <- queryGitHubPage ghEndpoint
+    case pageNext pageLinks of
+      Just next -> do
+        rest <- queryGitHubAll ghEndpoint { endpoint = next, endpointVals = [] }
+        return $ payload <> rest
+      Nothing -> return payload
+
+  -- | 'queryGitHub', except ignores the result.
+  queryGitHub_ :: GHEndpoint -> m ()
+  queryGitHub_ = void . queryGitHub @_ @Value
+
+{- Instances for common monad transformers -}
+
+instance MonadGitHubREST m => MonadGitHubREST (ReaderT r m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance MonadGitHubREST m => MonadGitHubREST (ExceptT e m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance MonadGitHubREST m => MonadGitHubREST (IdentityT m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance MonadGitHubREST m => MonadGitHubREST (MaybeT m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance (Monoid w, MonadGitHubREST m) => MonadGitHubREST (Lazy.RWST r w s m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance (Monoid w, MonadGitHubREST m) => MonadGitHubREST (Strict.RWST r w s m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance MonadGitHubREST m => MonadGitHubREST (Lazy.StateT s m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance MonadGitHubREST m => MonadGitHubREST (Strict.StateT s m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance (Monoid w, MonadGitHubREST m) => MonadGitHubREST (Lazy.WriterT w m) where
+  queryGitHubPage' = lift . queryGitHubPage'
+
+instance (Monoid w, MonadGitHubREST m) => MonadGitHubREST (Strict.WriterT w m) where
+  queryGitHubPage' = lift . queryGitHubPage'
diff --git a/src/GitHub/REST/PageLinks.hs b/src/GitHub/REST/PageLinks.hs
new file mode 100644
--- /dev/null
+++ b/src/GitHub/REST/PageLinks.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module GitHub.REST.PageLinks
+  ( PageLinks(..)
+  , parsePageLinks
+  ) where
+
+import Data.Maybe (fromMaybe)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Semigroup (Semigroup(..))
+#endif
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+-- | Helper type for GitHub pagination.
+--
+-- https://developer.github.com/v3/guides/traversing-with-pagination/
+data PageLinks = PageLinks
+  { pageFirst :: Maybe Text
+  , pagePrev  :: Maybe Text
+  , pageNext  :: Maybe Text
+  , pageLast  :: Maybe Text
+  } deriving (Eq,Show)
+
+instance Semigroup PageLinks where
+  links1 <> links2 = PageLinks
+    (pageFirst links1 <> pageFirst links2)
+    (pagePrev links1 <> pagePrev links2)
+    (pageNext links1 <> pageNext links2)
+    (pageLast links1 <> pageLast links2)
+
+instance Monoid PageLinks where
+  mempty = PageLinks Nothing Nothing Nothing Nothing
+
+#if !MIN_VERSION_base(4,11,0)
+  mappend = (<>)
+#endif
+
+parsePageLinks :: Text -> PageLinks
+parsePageLinks = foldl resolve mempty . split ","
+  where
+    resolve :: PageLinks -> Text -> PageLinks
+    resolve pageLinks "" = pageLinks
+    resolve pageLinks link =
+      let (rel, url) = parsePageLink link
+      in case rel of
+        "first" -> pageLinks { pageFirst = Just url }
+        "prev" -> pageLinks { pagePrev = Just url }
+        "next" -> pageLinks { pageNext = Just url }
+        "last" -> pageLinks { pageLast = Just url }
+        _ -> error $ "Unknown rel in page link: " ++ show link
+
+-- | Parse a single page link, like:
+--
+-- <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=2>; rel="next"
+--
+-- Returns ("next", "/search/code?q=addClass+user%3Amozilla&page=2")
+parsePageLink :: Text -> (Text, Text)
+parsePageLink link = fromMaybe (error $ "Unknown page link: " ++ show link) $ do
+  (linkUrl, linkRel) <- case split ";" link of
+    [url, rel] -> pure (url, rel)
+    _ -> mempty
+
+  url <- Text.stripPrefix ghUrl $ dropAround "<" ">" linkUrl
+  rel <- case split "=" linkRel of
+    ["rel", linkRel'] -> pure $ dropAround "\"" "\"" linkRel'
+    _ -> mempty
+
+  pure (rel, url)
+  where
+    ghUrl = "https://api.github.com"
+
+{- Helpers -}
+
+-- | Split the given text by the given delimiter, stripping any surrounding whitespace.
+split :: Text -> Text -> [Text]
+split delim = map Text.strip . Text.splitOn delim
+
+-- | Drop the given strings at the beginning and end of the given text.
+dropAround :: Text -> Text -> Text -> Text
+dropAround begin end s = fromMaybe badDrop $ Text.stripSuffix end =<< Text.stripPrefix begin s
+  where
+    badDrop = error $ "Expected value to wrap within " ++ Text.unpack begin ++ "..." ++ Text.unpack end ++ ": " ++ Text.unpack s
diff --git a/test/Endpoint.hs b/test/Endpoint.hs
new file mode 100644
--- /dev/null
+++ b/test/Endpoint.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Endpoint (tests) where
+
+import Data.Function (on)
+import Data.List (nubBy)
+import Data.Maybe (fromMaybe)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+import Data.Semigroup (Semigroup)
+#endif
+import Data.String (IsString(..))
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.HTTP.Types (StdMethod(..))
+import Test.Tasty (TestTree)
+import Test.Tasty.QuickCheck
+
+import GitHub.REST.Endpoint
+import GitHub.REST.KeyValue (KeyValue(..))
+
+tests :: [TestTree]
+tests =
+  [ testProperty "endpointPath with no substitutions" $
+      forAll (listOf genNoSubstitute) $ \endpointComponents ->
+        let ghEndpoint = mkGHEndpoint endpointComponents []
+        in endpointPath ghEndpoint === concatComponents endpointComponents
+
+  , testProperty "endpointPath with substitutions" $
+      forAll (listOf genNoSubstitute) $ \withoutColons ->
+        forAll (listOf1 genSubstitute) $ \withColonAndVals' -> do
+          -- we don't support duplicate substitutions
+          let withColonAndVals = nubBy ((==) `on` fst) withColonAndVals'
+
+          -- list of components before/after substituting
+          let allComponents = map (\ec -> (ec, ec)) withoutColons ++ withColonAndVals
+          (before, after) <- unzip <$> shuffle allComponents
+
+          let ghEndpoint = mkGHEndpoint before $ map fromSubstitutePair withColonAndVals
+
+          pure $ endpointPath ghEndpoint === concatComponents after
+  ]
+
+{- Helpers -}
+
+mkGHEndpoint :: [EndpointComponent] -> EndpointVals -> GHEndpoint
+mkGHEndpoint endpointComponents endpointVals = GHEndpoint
+  { method = GET
+  , endpoint = concatComponents endpointComponents
+  , endpointVals
+  , ghData = []
+  }
+
+concatComponents :: [EndpointComponent] -> Text
+concatComponents = ("/" <>) . Text.intercalate "/" . map unComponent
+
+-- | A component in an endpoint path; e.g. does not contain forward slashes.
+newtype EndpointComponent = EndpointComponent { unComponent :: Text }
+  deriving (Show,Eq,IsString,Monoid,Semigroup)
+
+-- | Generate a non-substitutionable EndpointComponent.
+genNoSubstitute :: Gen EndpointComponent
+genNoSubstitute = fmap fromString $ listOf1 $ elements allowedUrlCharacters
+  where
+    -- https://stackoverflow.com/a/41353282
+    allowedUrlCharacters = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ "-_.~"
+
+-- | Generate a substitutionable EndpointComponent and a value to interpolate.
+genSubstitute :: Gen (EndpointComponent, EndpointComponent)
+genSubstitute = do
+  component <- genNoSubstitute
+  val <- genNoSubstitute
+  pure (":" <> component, val)
+
+fromSubstitutePair :: (EndpointComponent, EndpointComponent) -> KeyValue
+fromSubstitutePair (EndpointComponent key, EndpointComponent val) = key' := val
+  where
+    key' = fromMaybe key $ Text.stripPrefix ":" key
diff --git a/test/Helpers.hs b/test/Helpers.hs
new file mode 100644
--- /dev/null
+++ b/test/Helpers.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Helpers (tests) where
+
+import Data.Aeson.QQ (aesonQQ)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree)
+import Test.Tasty.QuickCheck
+
+import GitHub.REST ((.:))
+
+tests :: [TestTree]
+tests =
+  [ testProperty "o .: key" $ \(PrintableString key) (PrintableString val) ->
+      [aesonQQ| { $key: #{val} } |] .: Text.pack key === val
+  ]
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,16 @@
+import Test.Tasty (defaultMain, testGroup)
+
+import qualified Endpoint
+import qualified Helpers
+import qualified MockQuery
+import qualified PageLinks
+import qualified Query
+
+main :: IO ()
+main = defaultMain $ testGroup "github-rest"
+  [ testGroup "GitHub.REST (Helpers)" Helpers.tests
+  , testGroup "GitHub.REST.Endpoint" Endpoint.tests
+  , testGroup "GitHub.REST.Monad.Class" MockQuery.tests
+  , testGroup "GitHub.REST.PageLinks" PageLinks.tests
+  , testGroup "GitHub.REST (End-to-End)" Query.tests
+  ]
diff --git a/test/MockQuery.hs b/test/MockQuery.hs
new file mode 100644
--- /dev/null
+++ b/test/MockQuery.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module MockQuery (tests) where
+
+import Control.Applicative ((<|>))
+import Control.Exception (SomeException, try)
+#if !MIN_VERSION_base(4,13,0)
+import Control.Monad.Fail (MonadFail)
+#endif
+import Control.Monad.State.Strict (StateT, evalStateT, get, put)
+import Data.Aeson (Result(..), Value(..), fromJSON)
+import Data.Either (isLeft)
+import Data.List (uncons)
+import Data.Maybe (fromMaybe, isJust, isNothing)
+import Data.Text (Text)
+import GHC.Exts (fromList)
+import Network.HTTP.Types (StdMethod(..))
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit
+
+import GitHub.REST.Endpoint (GHEndpoint(..))
+import GitHub.REST.Monad.Class
+import GitHub.REST.PageLinks (PageLinks(..))
+
+tests :: [TestTree]
+tests =
+  [ testGroup "queryGitHubPage"
+    [ testCase "success single" $ do
+        (result, pageLinks) <- runMockREST (queryGitHubPage @_ @Text) [Right "a"]
+        result @?= "a"
+        isNothing (pageNext pageLinks) @? "PageLinks 'next' rel is not Nothing"
+    , testCase "success multiple" $ do
+        (result, pageLinks) <- runMockREST (queryGitHubPage @_ @Text) [Right "a", Right "b"]
+        result @?= "a"
+        isJust (pageNext pageLinks) @? "PageLinks 'next' rel is Nothing"
+    , testCase "error single" $ do
+        result <- try @SomeException $ runMockREST (queryGitHubPage @_ @Text) [Left "error message!"]
+        isLeft result @? "query did not error"
+    , testCase "success with error in second page" $ do
+        (result, pageLinks) <- runMockREST (queryGitHubPage @_ @Text) [Right "a", Left "error message!"]
+        result @?= "a"
+        isJust (pageNext pageLinks) @? "PageLinks 'next' rel is Nothing"
+    ]
+
+  , testGroup "queryGitHub"
+    [ testCase "success single" $ do
+        result <- runMockREST (queryGitHub @_ @Text) [Right "a"]
+        result @?= "a"
+    , testCase "success multiple" $ do
+        result <- runMockREST (queryGitHub @_ @Text) [Right "a", Right "b"]
+        result @?= "a"
+    , testCase "error single" $ do
+        result <- try @SomeException $ runMockREST (queryGitHub @_ @Text) [Left "error message!"]
+        isLeft result @? "query did not error"
+    , testCase "success with error in second page" $ do
+        result <- runMockREST (queryGitHub @_ @Text) [Right "a", Left "error message!"]
+        result @?= "a"
+    ]
+
+  , testGroup "queryGitHubAll"
+    [ testCase "success single" $ do
+        result <- runMockREST (queryGitHubAll @_ @[Text]) [Right "a"]
+        result @?= ["a"]
+    , testCase "success multiple" $ do
+        result <- runMockREST (queryGitHubAll @_ @[Text]) [Right "a", Right "b"]
+        result @?= ["a", "b"]
+    , testCase "error single" $ do
+        result <- try @SomeException $ runMockREST (queryGitHubAll @_ @[Text]) [Left "error message!"]
+        isLeft result @? "query did not error"
+    , testCase "error in second page" $ do
+        result <- try @SomeException $ runMockREST (queryGitHubAll @_ @[Text]) [Right "a", Left "error message!"]
+        isLeft result @? "query did not error"
+    ]
+
+  , testGroup "queryGitHub_"
+    [ testCase "success single" $
+        runMockREST queryGitHub_ [Right "a"]
+    , testCase "success multiple" $
+        runMockREST queryGitHub_ [Right "a", Right "b"]
+    , testCase "error single" $ do
+        result <- try @SomeException $ runMockREST queryGitHub_ [Left "error message!"]
+        isLeft result @? "query did not error"
+    , testCase "success with error in second page" $
+        runMockREST queryGitHub_ [Right "a", Left "error message!"]
+    ]
+  ]
+
+{- Mock implementation -}
+
+newtype MockREST a = MockREST { unMockREST :: StateT [Either Text Text] IO a }
+  deriving (Functor,Applicative,Monad,MonadFail)
+
+runMockREST :: (GHEndpoint -> MockREST a) -> [Either Text Text] -> IO a
+runMockREST f results = (`evalStateT` results) $ unMockREST $ f ghEndpoint
+  where
+    ghEndpoint = GHEndpoint
+      { method = GET
+      , endpoint = "/"
+      , endpointVals = []
+      , ghData = []
+      }
+
+instance MonadGitHubREST MockREST where
+  queryGitHubPage' _ = do
+    (curr, rest) <- fromMaybe (error "Did you forget to mock a query?") . uncons <$> MockREST get
+    MockREST $ put rest
+
+    case curr of
+      Left e -> return $ Left (e, e)
+      Right v -> do
+        result <- case fromJSON (String v) <|> fromJSON (Array $ fromList [String v]) of
+          Success a -> return a
+          Error _ -> error "MockREST only supports returning Text or [Text]"
+
+        let pageLinks = case rest of
+              [] -> mempty
+              _ -> mempty { pageNext = Just "/" }
+
+        return $ Right (result, pageLinks)
diff --git a/test/PageLinks.hs b/test/PageLinks.hs
new file mode 100644
--- /dev/null
+++ b/test/PageLinks.hs
@@ -0,0 +1,40 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module PageLinks (tests) where
+
+import Data.Maybe (catMaybes)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Monoid ((<>))
+#endif
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree)
+import Test.Tasty.QuickCheck
+
+import GitHub.REST.PageLinks
+
+tests :: [TestTree]
+tests =
+  [ testProperty "parsePageLinks" $
+      forAll genPageLinks $ \(pageFirst, pagePrev, pageNext, pageLast) -> do
+        pageLinks <- shuffle $ catMaybes
+          [ mkPageLink "first" <$> pageFirst
+          , mkPageLink "prev" <$> pagePrev
+          , mkPageLink "next" <$> pageNext
+          , mkPageLink "last" <$> pageLast
+          ]
+
+        return $ parsePageLinks (Text.intercalate ", " pageLinks) === PageLinks{..}
+  ]
+
+mkPageLink :: Text -> Text -> Text
+mkPageLink rel link = "<https://api.github.com" <> link <> ">; rel=\"" <> rel <> "\""
+
+genPageLinks :: Gen (Maybe Text, Maybe Text, Maybe Text, Maybe Text)
+genPageLinks = (,,,) <$> genPageLink <*> genPageLink <*> genPageLink <*> genPageLink
+  where
+    genPageLink = liftArbitrary $ Text.pack <$> genUrl
+    genUrl = ('/' :) <$> listOf (elements urlChars)
+    urlChars = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ "-_.~?=+%&/"
diff --git a/test/Query.hs b/test/Query.hs
new file mode 100644
--- /dev/null
+++ b/test/Query.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Query (tests) where
+
+import Data.Aeson (Value)
+import Data.ByteString.Lazy (ByteString)
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import qualified Data.Text.Lazy.Encoding as Text
+import Network.HTTP.Types (status404)
+import Test.Tasty (TestName, TestTree)
+import Test.Tasty.Golden (goldenVsString)
+
+import GitHub.REST
+
+tests :: [TestTree]
+tests =
+  [ goldens "Query Gist #1" "gists-gist_id-sha.golden" $ do
+      gist <- queryGitHub $ getGist 1 "8afc134bf14f8f56ed2e7234128490d9946e8c16"
+      return $ Text.encodeUtf8 $ gist .: "files" .: "gistfile1.txt" .: "content"
+  , goldens "Query non-existent Gist commit" "gists-gist_id-sha-422.golden" $ do
+      showResult . githubTry . queryGitHub $
+        getGist 1 "d00770679ba293a327156b9e7031c47c6d269157"
+  , goldens "Query non-existent Gist" "gists-gist_id-sha-404.golden" $ do
+      showResult . githubTry' status404 . queryGitHub $
+        getGist 0 "d00770679ba293a327156b9e7031c47c6d269157"
+  ]
+
+goldens :: TestName -> String -> GitHubT IO ByteString -> TestTree
+goldens name fp action = goldenVsString name ("test/goldens/" ++ fp) $ runGitHubT state action
+  where
+    state = GitHubState
+      { token = Nothing
+      , userAgent = "github-rest"
+      , apiVersion = "v3"
+      }
+
+showResult :: Monad m => m (Either Value Value) -> m ByteString
+showResult m = m >>= \case
+  Right v -> error $ "Got back invalid result: " ++ show v
+  Left e -> return $ Char8.pack $ show e
+
+getGist :: Int -> String -> GHEndpoint
+getGist gistId gistSha = GHEndpoint
+  { method = GET
+  , endpoint = "/gists/:gist_id/:sha"
+  , endpointVals =
+      [ "gist_id" := gistId
+      , "sha" := gistSha
+      ]
+  , ghData = []
+  }
