Skip to content

Commit 5973d02

Browse files
authored
Add new Servant.API.Range type (#1805)
1 parent 591ed85 commit 5973d02

File tree

5 files changed

+133
-46
lines changed

5 files changed

+133
-46
lines changed

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ doc/tutorial/static/api.js
3232
doc/tutorial/static/jq.js
3333
shell.nix
3434
.hspec-failures
35+
.vscode
3536

3637
# nix
3738
result*

Diff for: changelog.d/1805

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
synopsis: Add Servant.API.Range type
2+
prs: #1805
3+
4+
description {
5+
Adds a new type `Range` which is a newtype wrapper around `Natural` that ensures the value is within a given range.
6+
}

Diff for: servant-client/test/Servant/ClientTestUtils.hs

+54-46
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
{-# LANGUAGE FlexibleContexts #-}
55
{-# LANGUAGE FlexibleInstances #-}
66
{-# LANGUAGE GADTs #-}
7+
{-# LANGUAGE LambdaCase #-}
78
{-# LANGUAGE MultiParamTypeClasses #-}
89
{-# LANGUAGE OverloadedStrings #-}
910
{-# LANGUAGE PolyKinds #-}
@@ -15,68 +16,60 @@
1516
{-# OPTIONS_GHC -freduction-depth=100 #-}
1617
{-# OPTIONS_GHC -fno-warn-orphans #-}
1718
{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
18-
{-# LANGUAGE EmptyCase #-}
1919

2020
module Servant.ClientTestUtils where
2121

2222
import Prelude ()
2323
import Prelude.Compat
2424

25-
import Control.Concurrent
26-
(ThreadId, forkIO, killThread)
27-
import Control.Monad
28-
(join)
29-
import Control.Monad.Error.Class
30-
(throwError)
25+
import Control.Concurrent (ThreadId, forkIO, killThread)
26+
import Control.Monad (join)
27+
import Control.Monad.Error.Class (throwError)
3128
import Data.Aeson
32-
import Data.ByteString
33-
(ByteString)
29+
import Data.ByteString (ByteString)
30+
import Data.ByteString.Builder (byteString)
3431
import qualified Data.ByteString.Char8 as C8
35-
import Data.ByteString.Builder
36-
(byteString)
3732
import qualified Data.ByteString.Lazy as LazyByteString
38-
import Data.Char
39-
(chr, isPrint)
40-
import Data.Maybe (fromMaybe)
33+
import Data.Char (chr, isPrint)
34+
import Data.Maybe (fromMaybe)
4135
import Data.Monoid ()
4236
import Data.Proxy
4337
import Data.SOP
44-
import Data.Text
45-
(Text)
38+
import Data.Text (Text)
4639
import qualified Data.Text as Text
47-
import Data.Text.Encoding
48-
(decodeUtf8, encodeUtf8)
49-
import GHC.Generics
50-
(Generic)
51-
import qualified Generics.SOP as GSOP
40+
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
41+
import qualified Generics.SOP as GSOP
42+
import GHC.Generics (Generic)
5243
import qualified Network.HTTP.Client as C
5344
import qualified Network.HTTP.Types as HTTP
5445
import Network.Socket
5546
import qualified Network.Wai as Wai
5647
import Network.Wai.Handler.Warp
57-
import System.IO.Unsafe
58-
(unsafePerformIO)
48+
import System.IO.Unsafe (unsafePerformIO)
5949
import Test.QuickCheck
60-
import Text.Read (readMaybe)
61-
import Web.FormUrlEncoded
62-
(FromForm, ToForm)
50+
import Text.Read (readMaybe)
51+
import Web.FormUrlEncoded (FromForm, ToForm)
6352

6453
import Servant.API
65-
((:<|>) ((:<|>)), (:>), AuthProtect, BasicAuth,
66-
BasicAuthData (..), Capture, CaptureAll, DeepQuery, DeleteNoContent,
67-
EmptyAPI, FormUrlEncoded, Fragment, FromHttpApiData (..), Get, Header, Headers,
68-
JSON, MimeRender (mimeRender), MimeUnrender (mimeUnrender),
54+
(AuthProtect, BasicAuth, BasicAuthData (..), Capture,
55+
CaptureAll, DeepQuery, DeleteNoContent, EmptyAPI,
56+
FormUrlEncoded, Fragment, FromHttpApiData (..), Get, Header,
57+
Headers, Host, JSON, MimeRender (mimeRender),
58+
MimeUnrender (mimeUnrender), NamedRoutes,
6959
NoContent (NoContent), PlainText, Post, QueryFlag, QueryParam,
70-
QueryParams, QueryString, Raw, ReqBody, StdMethod (GET), ToHttpApiData (..),
71-
UVerb, Union, Verb, WithStatus (WithStatus), NamedRoutes, addHeader, Host)
72-
import Servant.API.Generic ((:-))
73-
import Servant.API.QueryString (FromDeepQuery(..), ToDeepQuery(..))
60+
QueryParams, QueryString, Raw, ReqBody, StdMethod (GET),
61+
ToHttpApiData (..), UVerb, Union, Verb,
62+
WithStatus (WithStatus), addHeader, (:<|>) ((:<|>)), (:>))
63+
import Servant.API.Generic ((:-))
64+
import Servant.API.MultiVerb
65+
import Servant.API.QueryString
66+
(FromDeepQuery (..), ToDeepQuery (..))
67+
import Servant.API.Range
7468
import Servant.Client
7569
import qualified Servant.Client.Core.Auth as Auth
7670
import Servant.Server
7771
import Servant.Server.Experimental.Auth
7872
import Servant.Test.ComprehensiveAPI
79-
import Servant.API.MultiVerb
8073

8174
-- This declaration simply checks that all instances are in place.
8275
_ = client comprehensiveAPIWithoutStreaming
@@ -152,7 +145,7 @@ instance ToDeepQuery Filter where
152145
-----------------------------
153146

154147
-- This is the list of all possible responses
155-
type MultipleChoicesIntResponses =
148+
type MultipleChoicesIntResponses =
156149
'[ RespondEmpty 400 "Negative"
157150
, Respond 200 "Even number" Bool
158151
, Respond 200 "Odd number" Int
@@ -222,6 +215,7 @@ type Api =
222215
:<|> "multiple-choices-int" :> MultipleChoicesInt
223216
:<|> "captureVerbatim" :> Capture "someString" Verbatim :> Get '[PlainText] Text
224217
:<|> "host-test" :> Host "servant.example" :> Get '[JSON] Bool
218+
:<|> PaginatedAPI
225219

226220
api :: Proxy Api
227221
api = Proxy
@@ -258,6 +252,7 @@ recordRoutes :: RecordRoutes (AsClientT ClientM)
258252
multiChoicesInt :: Int -> ClientM MultipleChoicesIntResult
259253
captureVerbatim :: Verbatim -> ClientM Text
260254
getHost :: ClientM Bool
255+
getPaginatedPerson :: Maybe (Range 1 100) -> ClientM [Person]
261256

262257
getRoot
263258
:<|> getGet
@@ -288,7 +283,8 @@ getRoot
288283
:<|> recordRoutes
289284
:<|> multiChoicesInt
290285
:<|> captureVerbatim
291-
:<|> getHost = client api
286+
:<|> getHost
287+
:<|> getPaginatedPerson = client api
292288

293289
server :: Application
294290
server = serve api (
@@ -299,10 +295,10 @@ server = serve api (
299295
:<|> (\ name -> return $ Person name 0)
300296
:<|> (\ names -> return (zipWith Person names [0..]))
301297
:<|> return
302-
:<|> (\ name -> case name of
303-
Just "alice" -> return alice
304-
Just n -> throwError $ ServerError 400 (n ++ " not found") "" []
305-
Nothing -> throwError $ ServerError 400 "missing parameter" "" [])
298+
:<|> (\case
299+
Just "alice" -> return alice
300+
Just n -> throwError $ ServerError 400 (n ++ " not found") "" []
301+
Nothing -> throwError $ ServerError 400 "missing parameter" "" [])
306302
:<|> const (Tagged $ \request respond ->
307303
respond . maybe (Wai.responseLBS HTTP.notFound404 [] "Missing: payload")
308304
(Wai.responseLBS HTTP.ok200 [] . LazyByteString.fromStrict)
@@ -338,21 +334,22 @@ server = serve api (
338334
:<|> RecordRoutes
339335
{ version = pure 42
340336
, echo = pure
341-
, otherRoutes = \_ -> OtherRoutes
337+
, otherRoutes = const OtherRoutes
342338
{ something = pure ["foo", "bar", "pweet"]
343339
}
344340
}
345-
:<|> (\param ->
346-
if param < 0
341+
:<|> (\param ->
342+
if param < 0
347343
then pure NegativeNumber
348344
else
349-
if even param
345+
if even param
350346
then pure $ Odd 3
351347
else pure $ Even True
352348
)
353-
349+
354350
:<|> pure . decodeUtf8 . unVerbatim
355351
:<|> pure True
352+
:<|> usersServer
356353
)
357354

358355
-- * api for testing failures
@@ -473,3 +470,14 @@ instance ToHttpApiData Verbatim where
473470

474471
instance FromHttpApiData Verbatim where
475472
parseUrlPiece = pure . Verbatim . encodeUtf8
473+
474+
-- * range type example
475+
476+
type PaginatedAPI =
477+
"users" :> QueryParam "page" (Range 1 100) :> Get '[JSON] [Person]
478+
479+
usersServer :: Maybe (Range 1 100) -> Handler [Person]
480+
usersServer mpage = do
481+
let pageNum = maybe 1 unRange mpage
482+
-- pageNum is guaranteed to be between 1 and 100
483+
return [Person "Example" $ fromIntegral pageNum]

Diff for: servant/servant.cabal

+1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ library
9696
Servant.API.NamedRoutes
9797
Servant.API.QueryParam
9898
Servant.API.QueryString
99+
Servant.API.Range
99100
Servant.API.Raw
100101
Servant.API.RemoteHost
101102
Servant.API.ReqBody

Diff for: servant/src/Servant/API/Range.hs

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
2+
3+
module Servant.API.Range (Range (unRange), unsafeRange, mkRange) where
4+
5+
import Data.Aeson
6+
import Data.Aeson.Types (modifyFailure)
7+
import Data.Bifunctor (first)
8+
import Data.Ix
9+
import Data.Proxy (Proxy (Proxy))
10+
import qualified Data.Text as T
11+
import GHC.Generics (Generic)
12+
import GHC.TypeLits
13+
import Servant.API
14+
15+
-- | A newtype wrapper around 'Natural' that ensures the value is within a given range.
16+
--
17+
-- Example:
18+
--
19+
-- >>> :{
20+
-- let validRange = mkRange 5 :: Maybe (Range 1 10)
21+
-- in case validRange of
22+
-- Just r -> "Valid range: " ++ show (unRange r)
23+
-- Nothing -> "Invalid range"
24+
-- :}
25+
-- "Valid range: 5"
26+
--
27+
-- >>> :{
28+
-- let invalidRange = mkRange 15 :: Maybe (Range 1 10)
29+
-- in case invalidRange of
30+
-- Just r -> "Valid range: " ++ show (unRange r)
31+
-- Nothing -> "Invalid range"
32+
-- :}
33+
-- "Invalid range"
34+
--
35+
-- >>> decode "5" :: Maybe (Range 1 10)
36+
-- Just (MkRange {unRange = 5})
37+
--
38+
-- >>> decode "15" :: Maybe (Range 1 10)
39+
-- Nothing
40+
newtype Range (min :: Nat) (max :: Nat) = MkRange {unRange :: Natural}
41+
deriving stock (Eq, Ord, Show, Generic)
42+
deriving newtype (Ix, ToJSON)
43+
44+
unsafeRange :: Natural -> Range min max
45+
unsafeRange = MkRange
46+
47+
instance (KnownNat min, KnownNat max) => Bounded (Range min max) where
48+
minBound = MkRange . fromInteger $ natVal (Proxy @min)
49+
maxBound = MkRange . fromInteger $ natVal (Proxy @max)
50+
51+
parseErrorMsg :: forall min max. (KnownNat min, KnownNat max) => Proxy (Range min max) -> String
52+
parseErrorMsg _ =
53+
"Expecting a natural number between " <> show (natVal (Proxy @min)) <> " and " <> show (natVal (Proxy @max)) <> "."
54+
55+
mkRange :: forall min max. (KnownNat min, KnownNat max) => Natural -> Maybe (Range min max)
56+
mkRange n
57+
| inRange (minBound :: Range min max, maxBound :: Range min max) (MkRange n) = Just (MkRange n)
58+
| otherwise = Nothing
59+
60+
instance (KnownNat min, KnownNat max) => FromJSON (Range min max) where
61+
parseJSON v = do
62+
n <- modifyFailure (const $ parseErrorMsg @min @max Proxy) $ parseJSON v
63+
maybe (fail $ parseErrorMsg @min @max Proxy) pure $ mkRange n
64+
65+
instance (KnownNat min, KnownNat max) => ToHttpApiData (Range min max) where
66+
toQueryParam = T.pack . show . unRange
67+
68+
instance (KnownNat min, KnownNat max) => FromHttpApiData (Range min max) where
69+
parseQueryParam v = do
70+
n <- first (const . T.pack $ parseErrorMsg @min @max Proxy) $ parseQueryParam v
71+
maybe (Left . T.pack $ parseErrorMsg @min @max Proxy) Right $ mkRange n

0 commit comments

Comments
 (0)