Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce VersionNumber newtype. #3075

Merged
merged 42 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
c9b99ed
Introduce VersionNumber newtype.
fisx Feb 10, 2023
31c9889
Tweak hlint.
fisx Feb 11, 2023
86c4b1f
Fixup
fisx Feb 11, 2023
53758c3
hi ci
fisx Feb 12, 2023
a8d8ed7
Tweak changelog entry and haddocs.
fisx Feb 13, 2023
d2e52a1
More haddocks.
fisx Feb 13, 2023
ccd4650
Update libs/wire-api/src/Wire/API/Routes/Version.hs
fisx Feb 13, 2023
5d05349
Revert "Tweak hlint."
fisx Feb 13, 2023
e599619
Tweak hlint *locally*.
fisx Feb 13, 2023
ecf5249
D'OH!
fisx Feb 13, 2023
6780480
Merge remote-tracking branch 'refs/remotes/origin/play-with-version-t…
fisx Feb 13, 2023
4aa8e48
hi ci
fisx Feb 13, 2023
7a2bdde
hi ci
fisx Feb 13, 2023
5ecadff
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Feb 17, 2023
7e60211
Generic rendering of Version strings via `versionInt`.
fisx Feb 17, 2023
416abf1
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Feb 17, 2023
8b64085
Add pregenerated v3 swagger
pcapriotti Mar 2, 2023
81d062f
Finalise API v3
pcapriotti Mar 2, 2023
aad079e
Use v2 for welcome messages in tests
pcapriotti Mar 3, 2023
262ccb4
Add CHANGELOG entry
pcapriotti Mar 3, 2023
d09820d
Set v4 as the development version
pcapriotti Mar 3, 2023
a11e0da
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Mar 5, 2023
7589b2d
hi ci
fisx Mar 5, 2023
f02ae72
Update golden tests
pcapriotti Mar 6, 2023
d3b4f24
Add assertion for v4 to version test
pcapriotti Mar 6, 2023
d4d6ae4
Use v2 welcome in end2end tests
pcapriotti Mar 6, 2023
c21c33a
hi ci
fisx Mar 6, 2023
d2a2c39
Merge branch 'pcapriotti/finalise-v3' into play-with-version-types
fisx Mar 6, 2023
45f30a1
run-services: add status reponses to failure msg
smatting Mar 6, 2023
030f805
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Mar 6, 2023
0bbbdd8
using proof in test with max expiration time until 2038
battermann Mar 6, 2023
5d7b5f5
changelog
battermann Mar 6, 2023
5cae797
Merge remote-tracking branch 'origin/battermann/fix-dpop-access-token…
fisx Mar 6, 2023
5199887
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Mar 6, 2023
5213043
Fix versionMiddleware.
fisx Mar 7, 2023
33aefc6
Test versionMiddleware.
fisx Mar 7, 2023
759750f
Recover human-readable error messages (almost).
fisx Mar 7, 2023
1fdee85
Merge remote-tracking branch 'origin/develop' into play-with-version-…
fisx Mar 7, 2023
c454df8
Fixup
fisx Mar 7, 2023
e94dd83
hi ci
fisx Mar 7, 2023
1360860
Fix: make version parser in middleware more selective.
fisx Mar 7, 2023
18382ab
bike shed.
fisx Mar 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5-internal/play-with-version-types
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introduce VersionNumber newtype (see `/libs/wire-api/src/Wire/API/Routes/Version.hs` for explanation)
92 changes: 63 additions & 29 deletions libs/wire-api/src/Wire/API/Routes/Version.hs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE StandaloneKindSignatures #-}
{-# LANGUAGE TemplateHaskell #-}
-- This file is part of the Wire Server implementation.
Expand Down Expand Up @@ -28,18 +29,17 @@ module Wire.API.Routes.Version

-- * Version
Version (..),
VersionNumber (..),
supportedVersions,
developmentVersions,
readVersionNumber,
mkVersion,
toPathComponent,

-- * Servant combinators
Until,
From,
)
where

import Control.Error (note)
import Control.Lens ((?~))
import Data.Aeson (FromJSON, ToJSON (..))
import qualified Data.Aeson as Aeson
Expand All @@ -57,44 +57,78 @@ import Servant
import Servant.Swagger
import Wire.API.Routes.Named
import Wire.API.VersionInfo
import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform))

-- | Version of the public API.
-- | Version of the public API. Serializes to `"v<n>"`. See 'VersionNumber' below for one
-- that serializes to `<n>`. See `/libs/wire-api/test/unit/Test/Wire/API/Routes/Version.hs`
-- for serialization rules.
data Version = V0 | V1 | V2 | V3
deriving stock (Eq, Ord, Bounded, Enum, Show)
deriving stock (Eq, Ord, Bounded, Enum, Show, Generic)
deriving (FromJSON, ToJSON) via (Schema Version)
deriving (Arbitrary) via (GenericUniform Version)

-- | Manual enumeration of version strings.
--
-- If you want to implement this using `{to,from}Enum`, continue reading the haddocs for
-- 'versionInt' below. :-)
versionString :: IsString a => Version -> a
versionString V0 = "v0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be implemented with fromEnum.

The benefit would be that you won't have to touch this function when a new version is introduced. The drawback is that Version constructors have to be sorted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i used to agree, but been there! :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This simple version is fine with me, too 😸

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But surely it could be implemented in terms of versionInt.

versionString V1 = "v1"
versionString V2 = "v2"
versionString V3 = "v3"

-- | Manual enumeration of version integrals.
--
-- We don't do anything fancy with `{to,from}Enum`
-- because we'll eventually break the invariant that there is a `V<n>` for every `<n>` once we
-- start to deprecate old versions (we may even find a reason to discontinue `V13` but keep
-- supporting `V12`).
versionInt :: Integral i => Version -> i
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: Could be implemented with fromEnum.

versionInt V0 = 0
versionInt V1 = 1
versionInt V2 = 2
versionInt V3 = 3

instance ToSchema Version where
schema =
enum @Integer "Version" . mconcat $
[ element 0 V0,
element 1 V1,
element 2 V2,
element 3 V3
elland marked this conversation as resolved.
Show resolved Hide resolved
]

mkVersion :: Integer -> Maybe Version
mkVersion n = case Aeson.fromJSON (Aeson.Number (fromIntegral n)) of
Aeson.Error _ -> Nothing
Aeson.Success v -> pure v
schema = enum @Text "Version" . mconcat $ (\v -> element (versionString v) v) <$> [minBound ..]

instance FromHttpApiData Version where
parseQueryParam v = note ("Unknown version: " <> v) $
getAlt $
flip foldMap [minBound ..] $ \s ->
guard (versionString s == v) $> s

instance ToHttpApiData Version where
toHeader = versionString
toUrlPiece = versionString

instance ToByteString Version where
builder = versionString

-- | Wrapper around 'Version' that serializes to integers `<n>`, as needed in
-- eg. `VersionInfo`. See `/libs/wire-api/test/unit/Test/Wire/API/Routes/Version.hs` for
-- serialization rules.
newtype VersionNumber = VersionNumber {fromVersionNumber :: Version}
deriving stock (Eq, Ord, Show, Generic)
deriving newtype (Bounded, Enum)
deriving (FromJSON, ToJSON) via (Schema VersionNumber)
deriving (Arbitrary) via (GenericUniform Version)

instance ToSchema VersionNumber where
schema =
enum @Integer "VersionNumber" . mconcat $ (\v -> element (versionInt v) (VersionNumber v)) <$> [minBound ..]

instance FromHttpApiData VersionNumber where
parseHeader = first Text.pack . Aeson.eitherDecode . LBS.fromStrict
parseUrlPiece = parseHeader . Text.encodeUtf8

instance ToHttpApiData Version where
instance ToHttpApiData VersionNumber where
toHeader = LBS.toStrict . Aeson.encode
toUrlPiece = Text.decodeUtf8 . toHeader

instance ToByteString Version where
instance ToByteString VersionNumber where
builder = toEncodedUrlPiece

-- | `Version` as it appears in an URL path
--
-- >>> toPathComponent V1
-- "v1"
toPathComponent :: Version -> ByteString
toPathComponent v = "v" <> toHeader v

supportedVersions :: [Version]
supportedVersions = [minBound .. maxBound]

Expand All @@ -108,8 +142,8 @@ developmentVersions = [V3]
-- backend, in order to decide how to form request paths, and how to deal with
-- federated backends and qualified user IDs.
data VersionInfo = VersionInfo
{ vinfoSupported :: [Version],
vinfoDevelopment :: [Version],
{ vinfoSupported :: [VersionNumber],
vinfoDevelopment :: [VersionNumber],
vinfoFederation :: Bool,
vinfoDomain :: Domain
}
Expand All @@ -127,7 +161,7 @@ instance ToSchema VersionInfo where
example :: VersionInfo
example =
VersionInfo
{ vinfoSupported = supportedVersions,
{ vinfoSupported = VersionNumber <$> supportedVersions,
vinfoDevelopment = [maxBound],
vinfoFederation = False,
vinfoDomain = Domain "example.com"
Expand Down
35 changes: 23 additions & 12 deletions libs/wire-api/src/Wire/API/Routes/Version/Wai.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,45 @@

module Wire.API.Routes.Version.Wai where

import Control.Monad.Except (throwError)
import Data.ByteString.Conversion
import qualified Data.Text.Lazy as LText
import Data.EitherR (fmapL)
import Data.String.Conversions (cs)
import qualified Data.Text as T
import Imports
import qualified Network.HTTP.Types as HTTP
import Network.Wai
import Network.Wai.Middleware.Rewrite
import Network.Wai.Utilities.Error
import Network.Wai.Utilities.Response
import Web.HttpApiData (parseUrlPiece, toUrlPiece)
import Wire.API.Routes.Version

-- | Strip off version prefix. Return 404 if the version is not supported.
versionMiddleware :: Set Version -> Middleware
versionMiddleware disabledAPIVersions app req k = case parseVersion (removeVersionHeader req) of
Nothing -> app req k
Just (req', n) -> case mkVersion n of
Just v | v `notElem` disabledAPIVersions -> app (addVersionHeader v req') k
_ ->
k $
errorRs' $
mkError HTTP.status404 "unsupported-version" $
"Version " <> LText.pack (show n) <> " is not supported"
Right (req', v) ->
if v `elem` disabledAPIVersions
then err (toUrlPiece v)
else app (addVersionHeader v req') k
Left (BadVersion v) -> err v
Left NoVersion -> app req k
where
err :: Text -> IO ResponseReceived
err v =
k . errorRs' . mkError HTTP.status404 "unsupported-version" $
"Version " <> cs v <> " is not supported"

parseVersion :: Request -> Maybe (Request, Integer)
data ParseVersionError = NoVersion | BadVersion Text

parseVersion :: Request -> Either ParseVersionError (Request, Version)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this function cannot be generalized regarding its Monad: Shouldn't a constraint MonadError m => ... be sufficient?

(This is nitpicking as far as nitpicking can get ... 😉 )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that would be sufficient, but i intentionally decided against it. i like both the abstract interpretation and the precise type that offers exactly what's needed, and not more.

always happy to nit-pick, though! :)

parseVersion req = do
(version, pinfo) <- case pathInfo req of
[] -> Nothing
[] -> throwError NoVersion
(x : xs) -> pure (x, xs)
n <- readVersionNumber version
unless ("v" `T.isPrefixOf` version) $
throwError (BadVersion version)
n <- fmapL (const NoVersion) $ parseUrlPiece version
pure (rewriteRequestPure (\(_, q) _ -> (pinfo, q)) req, n)

removeVersionHeader :: Request -> Request
Expand Down
10 changes: 0 additions & 10 deletions libs/wire-api/src/Wire/API/VersionInfo.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ module Wire.API.VersionInfo
vinfoObjectSchema,

-- * Version utilities
readVersionNumber,
versionHeader,
VersionHeader,

Expand All @@ -36,8 +35,6 @@ import qualified Data.CaseInsensitive as CI
import Data.Metrics.Servant
import Data.Schema
import Data.Singletons
import qualified Data.Text as Text
import qualified Data.Text.Read as Text
import GHC.TypeLits
import Imports
import qualified Network.Wai as Wai
Expand All @@ -51,13 +48,6 @@ import Wire.API.Routes.ClientAlgebra
vinfoObjectSchema :: ValueSchema NamedSwaggerDoc v -> ObjectSchema SwaggerDoc [v]
vinfoObjectSchema sch = field "supported" (array sch)

readVersionNumber :: Text -> Maybe Integer
readVersionNumber v = do
('v', rest) <- Text.uncons v
case Text.decimal rest of
Right (n, "") -> pure n
_ -> Nothing

type VersionHeader = "X-Wire-API-Version"

versionHeader :: CI.CI ByteString
Expand Down
5 changes: 4 additions & 1 deletion libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import qualified Wire.API.Provider.External as Provider.External
import qualified Wire.API.Provider.Service as Provider.Service
import qualified Wire.API.Provider.Service.Tag as Provider.Service.Tag
import qualified Wire.API.Push.Token as Push.Token
import qualified Wire.API.Routes.Version as Routes.Version
import qualified Wire.API.Team as Team
import qualified Wire.API.Team.Conversation as Team.Conversation
import qualified Wire.API.Team.Feature as Team.Feature
Expand Down Expand Up @@ -310,7 +311,9 @@ tests =
testRoundTrip @User.Search.PagingState,
testRoundTrip @User.Search.TeamContact,
testRoundTrip @(Wrapped.Wrapped "some_int" Int),
testRoundTrip @Conversation.Action.SomeConversationAction
testRoundTrip @Conversation.Action.SomeConversationAction,
testRoundTrip @Routes.Version.Version,
testRoundTrip @Routes.Version.VersionNumber
]

testRoundTrip ::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Servant.API
import qualified Test.Tasty as T
import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (===))
import Type.Reflection (typeRep)
import qualified Wire.API.Routes.Version
import qualified Wire.API.User
import qualified Wire.API.User.Search
import qualified Wire.Arbitrary as Arbitrary ()
Expand All @@ -30,7 +31,9 @@ tests :: T.TestTree
tests =
T.localOption (T.Timeout (60 * 1000000) "60s") . T.testGroup "HttpApiData roundtrip tests" $
[ testRoundTrip @Wire.API.User.InvitationCode,
testRoundTrip @Wire.API.User.Search.PagingState
testRoundTrip @Wire.API.User.Search.PagingState,
testRoundTrip @Wire.API.Routes.Version.Version,
testRoundTrip @Wire.API.Routes.Version.VersionNumber
]

testRoundTrip ::
Expand Down
48 changes: 36 additions & 12 deletions libs/wire-api/test/unit/Test/Wire/API/Routes/Version.hs
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
module Test.Wire.API.Routes.Version where

import Data.Aeson as Aeson
import Data.Binary.Builder
import Data.ByteString.Conversion
import Data.String.Conversions (cs)
import Imports
import qualified Test.Tasty as T
import Servant.API
import Test.Tasty
import Test.Tasty.HUnit
import Wire.API.Routes.Version

tests :: T.TestTree
{-# ANN tests ("HLint: ignore Functor law" :: String) #-}
tests :: TestTree
tests =
T.testGroup "Version" $
[ T.testGroup
"toPathComponent"
[testCase "serialise different versions" testToPathComponent]
testGroup
"Version always has the shape V<int>; serializations of Version and VersionNumber are `v<int>`, `<int>`, resp. <int> is non-negative."
[ testCase "Version, show, 'v' prefix" $ do
fisx marked this conversation as resolved.
Show resolved Hide resolved
nub (toLower . head . show <$> allVersions) @=? ['v'],
testCase "Version, show, int suffix" $ do
let expected = show $ (read @Int) . tail . show <$> allVersions
assertBool expected (isJust (Aeson.decode @[Int] (cs expected))),
testGroup "Version: all serializations are the same as `show`, up to string type" $ do
[ testCase "toByteString'" $ fmap toLower . show <$> allVersions @=? cs . toByteString' <$> allVersions,
testCase "encode" $ fmap toLower (show (show <$> allVersions)) @=? cs (encode allVersions), -- (`encode @Version` has extra double-quotes)
testCase "toUrlPiece" $ fmap toLower . show <$> allVersions @=? cs . toUrlPiece <$> allVersions,
testCase "toEncodedUrlPiece" $ fmap toLower . show <$> allVersions @=? cs . toLazyByteString . toEncodedUrlPiece <$> allVersions,
testCase "toHeader" $ fmap toLower . show <$> allVersions @=? cs . toHeader <$> allVersions,
testCase "toQueryParam" $ fmap toLower . show <$> allVersions @=? cs . toQueryParam <$> allVersions
],
testGroup "VersionNumber: all serializations are the same as `tail . show . fromVersionNumber`, up to string type" $
[ testCase "toByteString'" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . toByteString' <$> allVersionNumbers,
testCase "encode" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . encode <$> allVersionNumbers,
testCase "toUrlPiece" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . toUrlPiece <$> allVersionNumbers,
testCase "toEncodedUrlPiece" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . toLazyByteString . toEncodedUrlPiece <$> allVersionNumbers,
testCase "toHeader" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . toHeader <$> allVersionNumbers,
testCase "toQueryParam" $ tail . show . fromVersionNumber <$> allVersionNumbers @=? cs . toQueryParam <$> allVersionNumbers
]
]

testToPathComponent :: Assertion
testToPathComponent = do
"v0" @=? toPathComponent V0
"v1" @=? toPathComponent V1
"v2" @=? toPathComponent V2
"v3" @=? toPathComponent V3
allVersions :: [Version]
allVersions = [minBound ..]

allVersionNumbers :: [VersionNumber]
allVersionNumbers = [minBound ..]
2 changes: 1 addition & 1 deletion services/brig/src/Brig/API/Public/Swagger.hs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pregenSwagger :: Version -> Q Exp
pregenSwagger v =
embedLazyByteString
=<< makeRelativeToProject
("docs/swagger-v" <> T.unpack (toUrlPiece v) <> ".json")
("docs/swagger-v" <> T.unpack (toUrlPiece (VersionNumber v)) <> ".json")

swaggerPregenUIServer :: LByteString -> Server VersionedSwaggerDocsAPIBase
swaggerPregenUIServer =
Expand Down
6 changes: 3 additions & 3 deletions services/brig/src/Brig/Effects/GalleyProvider/RPC.hs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ createSelfConv u = do
void $ ServiceRPC.request @'Galley POST req
where
req =
paths ["v" <> toHeader (maxBound :: Version), "conversations", "self"]
paths [toHeader (maxBound :: Version), "conversations", "self"]
. zUser u
. expect2xx

Expand Down Expand Up @@ -115,7 +115,7 @@ getConv usr lcnv = do
where
req =
paths
[ "v" <> toHeader (maxBound :: Version),
[ toHeader (maxBound :: Version),
"conversations",
toByteString' (tDomain lcnv),
toByteString' (tUnqualified lcnv)
Expand Down Expand Up @@ -147,7 +147,7 @@ getTeamConv usr tid cnv = do
where
req =
paths
[ "v" <> toHeader (maxBound :: Version),
[ toHeader (maxBound :: Version),
"teams",
toByteString' tid,
"conversations",
Expand Down
4 changes: 2 additions & 2 deletions services/brig/src/Brig/Version.hs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ versionAPI = Named $ do
| otherwise = Set.difference allVersions devVersions
pure $
VersionInfo
{ vinfoSupported = toList supported,
vinfoDevelopment = toList devVersions,
{ vinfoSupported = VersionNumber <$> toList supported,
vinfoDevelopment = VersionNumber <$> toList devVersions,
vinfoFederation = isJust fed,
vinfoDomain = dom
}
Loading