packages feed

servant-routes (empty) → 0.1.0.0

raw patch · 31 files changed

+2757/−0 lines, 31 filesdep +QuickCheckdep +aesondep +aeson-pretty

Dependencies added: QuickCheck, aeson, aeson-pretty, base, containers, hspec, http-types, microlens, microlens-th, servant, servant-routes, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,21 @@+# Changelog++All notable changes to `servant-routes` will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),+and this project adheres to [Haskell Package Versioning Policy](https://pvp.haskell.org).++## [Unreleased]++## [0.1.0.0] - 03.05.2025++### Added++- First edition of the package.+- 100% documentation coverage.+- Almost 100% test coverage.+- Reasonably detailed README.+- CI that builds and tests the package for each version of GHC in the `tested-with` field.++[unreleased]: https://github.com/fpringle/servant-routes/compare/v0.1.0.0...HEAD+[0.1.0.0]: https://github.com/fpringle/servant-routes/releases/tag/v0.1.0.0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2025, Frederick Pringle++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * 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.++    * Neither the name of Frederick Pringle nor the names of other+      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+OWNER 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.
+ README.md view
@@ -0,0 +1,465 @@+# servant-routes++This package alllows us to automatically convert type-level Servant representations of APIs to concrete term-level representations.++See `Servant.API.Routes` for in-depth documentation.++## Contents++* [Motivation](#motivation)+* [Examples](#examples)+    * [Basic usage with servant combinators](#basic-usage-with-servant-combinators)+    * [Same example using NamedRoutes](#same-example-using-namedroutes)+    * [Writing HasRoutes instances for custom combinators](#writing-hasroutes-instances-for-custom-combinators)+* [Back story](#back-story)+++## Motivation++Refactoring Servant API types is quite error-prone, especially when you have to move+around lots of `:<|>`s and `:>`s.  So it's very possible that the route structure could+change in that refactoring, _without being caught by the type-checker_.++The `HasRoutes` class could help as a "golden test" - run `getRoutes` before and after the refactor, and if they give the same+result you can be much more confident that the refactor didn't introduce difficult bugs.++Another use-case is in testing: some Haskellers use type families to modify Servant APIs, for example+to add endpoints or authorisation headers. Types are hard to test, but terms are easy. Use `HasRoutes`+and run your tests on `Routes`.+++## Examples++#### Basic usage with `servant` combinators:++```haskell+type ServantAPI =+  "users"+    :> ( "list" :> Get '[JSON] [User]+          :<|> "create" :> ReqBody '[JSON] UserCreateData :> Post '[JSON] UserID+          :<|> "detail"+            :> QueryParam' '[Required] "id" UserID+            :> Header' '[Required] "x-api-key" ApiKey+            :> Get '[JSON] User+       )+    :<|> "transactions"+      :> ( Capture "id" TransactionID+            :> Get '[JSON] (Headers '[Header "x-request-id" RequestID] Transaction)+         )+    :<|> "admin"+      :> BasicAuth "admin" User+      :> ( "users" :> "delete" :> CaptureAll "ids" UserID :> Delete '[JSON] UserID+         )+```++```haskell+ghci> printRoutes @ServantAPI+GET /users/list+POST /users/create+GET /users/detail?id=<UserID>+GET /transactions/<TransactionID>+DELETE /admin/users/delete/<[UserID]>++ghci> BL.putStrLn . encodePretty $ getRoutes @ServantAPI -- using aeson-pretty+```++<details>+<summary>Click to see JSON output</summary>+  +```json+[+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/users/list",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "[User]"+        }+    },+    {+        "auths": [],+        "method": "POST",+        "params": [],+        "path": "/users/create",+        "request_body": "UserCreateData",+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "UserID"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [+            {+                "name": "id",+                "param_type": "UserID",+                "type": "SingleParam"+            }+        ],+        "path": "/users/detail",+        "request_body": null,+        "request_headers": [+            {+                "name": "x-api-key",+                "type": "ApiKey"+            }+        ],+        "response": {+            "headers": [],+            "type": "User"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/transactions/<TransactionID>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "x-request-id",+                    "type": "RequestID"+                }+            ],+            "type": "Transaction"+        }+    },+    {+        "auths": [+            "Basic admin"+        ],+        "method": "DELETE",+        "params": [],+        "path": "/admin/users/delete/<[UserID]>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "UserID"+        }+    }+]+```++</details>++#### Same example using `NamedRoutes`:++```haskell+data UserAPI mode = UserAPI+  { list :: mode :- "list" :> Get '[JSON] [User]+  , create :: mode :- "create" :> ReqBody '[JSON] UserCreateData :> Post '[JSON] UserID+  , detail ::+      mode+        :- "detail"+          :> QueryParam' '[Required] "id" UserID+          :> Header' '[Required] "x-api-key" ApiKey+          :> Get '[JSON] User+  }+  deriving (Generic)++newtype TransactionAPI mode = TransactionAPI+  { get ::+      Capture "id" TransactionID+        :> Get '[JSON] (Headers '[Header "x-request-id" RequestID] Transaction)+  }+  deriving (Generic)++newtype AdminAPI mode = AdminAPI+  { deleteUsers ::+      BasicAuth "admin" User+        :> "users"+        :> "delete"+        :> CaptureAll "ids" UserID+        :> Delete '[JSON] UserID+  }+  deriving (Generic)++data ServantAPIWithNamedRoutes mode = ServantAPIWithNamedRoutes+  { users :: mode :- "users" :> NamedRoutes UserAPI+  , transactions :: mode :- "transactions" :> NamedRoutes TransactionAPI+  , admin :: mode :- "admin" :> NamedRoutes AdminAPI+  }+  deriving (Generic)+```++```haskell+ghci> printRoutes @(NamedRoutes ServantAPIWithNamedRoutes)+GET /users/list+POST /users/create+GET /users/detail?id=<UserID>+GET /transactions/<TransactionID>+DELETE /admin/users/delete/<[UserID]>++ghci> BL.putStrLn . encodePretty $ getRoutes @(NamedRoutes ServantAPIWithNamedRoutes)+```++<details>+<summary>Click to see JSON output</summary>++Note this is the same as above, so we know we refactored `ServantAPI` to `ServantAPIWithNamedRoutes` correctly!+  +```json+[+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/users/list",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "[User]"+        }+    },+    {+        "auths": [],+        "method": "POST",+        "params": [],+        "path": "/users/create",+        "request_body": "UserCreateData",+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "UserID"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [+            {+                "name": "id",+                "param_type": "UserID",+                "type": "SingleParam"+            }+        ],+        "path": "/users/detail",+        "request_body": null,+        "request_headers": [+            {+                "name": "x-api-key",+                "type": "ApiKey"+            }+        ],+        "response": {+            "headers": [],+            "type": "User"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/transactions/<TransactionID>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "x-request-id",+                    "type": "RequestID"+                }+            ],+            "type": "Transaction"+        }+    },+    {+        "auths": [+            "Basic admin"+        ],+        "method": "DELETE",+        "params": [],+        "path": "/admin/users/delete/<[UserID]>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [],+            "type": "UserID"+        }+    }+]++```++</details>++#### Writing `HasRoutes` instances for custom combinators:++For the most part you'll be able to use `getRoutes` out of the box, without worrying about `HasRoutes` and other internals.+But sometimes you'll want to extend `servant` with your own custom combinators, writing `HasServer` or `HasClient` for them etc.+Then you'll also need to write a `HasRoutes` instance.++Let's write a `HasRoutes` instance for the `Replay` combinator in William Yao's+[Writing servant combinators for fun and profit](https://williamyaoh.com/posts/2023-02-28-writing-servant-combinators.html#:~:text=Example%3A%20Returning%20a%20header%20with%20a%20%22replay%22%20path).+The `HasServer` instance tells us that the effect of `Replay :> api` is to inherit the behaviour of `api`, but add an `X-Replay-Path` header of type `ByteString` to the response.++As far as our `HasRoutes` instance is concerned, this means that we need to:++1. Call `getRoutes` on `api` to get the list of un-modified routes+2. Add a `HeaderRep` to the `responseHeaders` field of all the possible responses of each route, with name `X-Replay-Path` and type-rep `ByteString`.++```haskell+data Replay++instance HasRoutes api => HasRoutes (Replay :> api) where+  getRoutes =+    let apiRoutes = getRoutes @api+        replayHeader = mkHeaderRep @"X-Replay-Path" @ByteString+        addHeader route = route & routeResponse . unResponses . traversed . responseHeaders %~ Set.insert replayHeader+    in  addHeader <$> apiRoutes+```++We can test the implementation on `ServantAPI` from above:++```haskell+ghci> BL.putStrLn . encodePretty $ getRoutes @(Replay :> ServantAPI)+```++<details>+<summary>Click to see JSON output</summary>++Note that each route is the same as above, but with an extra `response.header` `{"name": "X-Replay-Path", "type": "ByteString"}`:+  +```json+[+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/users/list",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "X-Replay-Path",+                    "type": "ByteString"+                }+            ],+            "type": "[User]"+        }+    },+    {+        "auths": [],+        "method": "POST",+        "params": [],+        "path": "/users/create",+        "request_body": "UserCreateData",+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "X-Replay-Path",+                    "type": "ByteString"+                }+            ],+            "type": "UserID"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [+            {+                "name": "id",+                "param_type": "UserID",+                "type": "SingleParam"+            }+        ],+        "path": "/users/detail",+        "request_body": null,+        "request_headers": [+            {+                "name": "x-api-key",+                "type": "ApiKey"+            }+        ],+        "response": {+            "headers": [+                {+                    "name": "X-Replay-Path",+                    "type": "ByteString"+                }+            ],+            "type": "User"+        }+    },+    {+        "auths": [],+        "method": "GET",+        "params": [],+        "path": "/transactions/<TransactionID>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "X-Replay-Path",+                    "type": "ByteString"+                },+                {+                    "name": "x-request-id",+                    "type": "RequestID"+                }+            ],+            "type": "Transaction"+        }+    },+    {+        "auths": [+            "Basic admin"+        ],+        "method": "DELETE",+        "params": [],+        "path": "/admin/users/delete/<[UserID]>",+        "request_body": null,+        "request_headers": [],+        "response": {+            "headers": [+                {+                    "name": "X-Replay-Path",+                    "type": "ByteString"+                }+            ],+            "type": "UserID"+        }+    }+]+```++</details>+++## Back story++The scenario I described in [Motivation](#motivation) arose while I was working with @asheshambasta at [CentralApp](https://www.centralapp.com/),+one of my freelancing clients. The CentralApp backend is a distributed system comprising of 565 Servant endpoints across 7 different services+(plus several more services that aren't using Servant). Many of these endpoints are deeply nested, and as anyone familiar with Servant knows, debugging+error messages can be very frustrating. The best solution to this problem is to use [NamedRoutes](https://hackage.haskell.org/package/servant/docs/Servant-API.html#t:NamedRoutes)+and derive `HasServer` instances using `Generic`. I took on the task of refactoring _every one_ of those 565 endpoints to use `NamedRoutes` instead of+`:<|>` and `:>`.++Not only was this process fiddly and tedious, it was also potentially error-prone. The type-checker helps, of course, and will let you know if you accidentally+swapped 2 handler functions. However, what if you miss out or misspell a path part (e.g. `"api" :> ...`)? These have no effect on the `ServerT` instances, and thus+can't be caught by the type-checker. We wouldn't know if we made this kind of error until after deployment, when an endpoint would suddenly be at completely the wrong location,+or expecting `QueryParam`s with the names switched round, or many other bugs caused by human error.++Having worked with [servant-openapi3](https://hackage.haskell.org/package/servant-openapi3), I got the idea for a much simpler version in order to solve the above problem.+If I could use a similar mechanism to convert API types to term-level values describing the shape and semantics of all the routes of the API, I could compare the+representation _before_ and _after_ the refactoring. If the lists of routes were identical, and assuming my representation of the routes was accurate and expressive enough,+I could be confident that I wasn't introducing any subtle bugs.++Fortunately, this approach worked! After refactoring those 565 endpoints to use `NamedRoutes`, I ran `getRoutes` and compared the output to the output from the commit _before_+the refactor. I used [jdiff](https://github.com/networktocode/jdiff) to compare the outputs in JSON form. The comparison revealed that _one_ endpoint had a subtle bug:+it was missing a path part, as described above. And the type-checker didn't catch the mistake, which confirmed the need for this package!+Having fixed the mistake, I deployed the refactoring without a single issue.
+ servant-routes.cabal view
@@ -0,0 +1,120 @@+cabal-version:      3.0+name:               servant-routes+version:            0.1.0.0+synopsis:           Generate route descriptions from Servant APIs+description:+    See the documentation of 'Servant.API.Routes'.+license:            BSD-3-Clause+license-file:       LICENSE+author:             Frederick Pringle+maintainer:         freddyjepringle@gmail.com+copyright:          Copyright(c) Frederick Pringle 2025+homepage:           https://github.com/fpringle/servant-routes+bug-reports:        https://github.com/fpringle/servant-routes/issues+category:           Servant, Web+build-type:         Simple+extra-doc-files:    CHANGELOG.md+                    README.md+tested-with:+    GHC == 8.8.4+  , GHC == 8.10.7+  , GHC == 9.0.2+  , GHC == 9.2.4+  , GHC == 9.2.8+  , GHC == 9.4.2+  , GHC == 9.4.5++source-repository head+  type:           git+  location:       https://github.com/fpringle/servant-routes++common warnings+  ghc-options: -Wall -Wno-unused-do-bind++common deps+  build-depends:+    , base >= 4.13 && < 5+    , servant >= 0.17 && < 0.21+    , aeson >= 2.0 && < 2.3+    , text >= 1.2 && < 2.2+    , http-types >= 0.12 && < 0.13+    , containers >= 0.6 && < 0.8+    , microlens >= 0.4.9 && < 0.5+    , microlens-th >= 0.4.3 && < 0.5+    , aeson-pretty >= 0.8.8 && < 0.9++common extensions+  default-extensions:+    DeriveGeneric+    DerivingVia +    FlexibleContexts+    FlexibleInstances+    LambdaCase+    NamedFieldPuns+    OverloadedStrings+    PackageImports+    RecordWildCards+    ScopedTypeVariables+    TypeApplications+    ViewPatterns+    DataKinds+    AllowAmbiguousTypes+    TypeFamilies+    TypeOperators++library+  import:+      warnings+    , deps+    , extensions+  ghc-options: -Wunused-packages+  exposed-modules:+    Servant.API.Routes+    Servant.API.Routes.Route+    Servant.API.Routes.Header+    Servant.API.Routes.Param+    Servant.API.Routes.Path+    Servant.API.Routes.Request+    Servant.API.Routes.Response+    Servant.API.Routes.Auth++    Servant.API.Routes.Internal.Some+    Servant.API.Routes.Internal.Header+    Servant.API.Routes.Internal.Param+    Servant.API.Routes.Internal.Path+    Servant.API.Routes.Internal.Request+    Servant.API.Routes.Internal.Response+    Servant.API.Routes.Internal.Route+    Servant.API.Routes.Internal.Auth+  other-modules:+    Servant.API.Routes.Utils+  -- other-extensions:+  hs-source-dirs:   src+  default-language: Haskell2010++test-suite servant-routes-test+  import:+      warnings+    , deps+    , extensions+  default-language: Haskell2010+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          Spec.hs+  other-modules:+    Servant.API.RoutesSpec+    Servant.API.Routes.Util+    Servant.API.Routes.RouteSpec+    Servant.API.Routes.HeaderSpec+    Servant.API.Routes.ParamSpec+    Servant.API.Routes.PathSpec+    Servant.API.Routes.RequestSpec+    Servant.API.Routes.ResponseSpec+    Servant.API.Routes.SomeSpec+  build-tool-depends:+      hspec-discover:hspec-discover+  ghc-options:      -Wno-orphans+  build-depends:+    , servant-routes+    , hspec >= 2.9 && < 2.12+    , QuickCheck >= 2.14 && < 2.15
+ src/Servant/API/Routes.hs view
@@ -0,0 +1,498 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE UndecidableInstances #-}++{- |+Module      : Servant.API.Routes+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++This package provides two things:++  1. A simple, and probably incomplete, way to represent APIs at the term level.+     This is achieved by the 'Route', t'Routes', 'Path', 'Param', 'HeaderRep',+     'Request', 'Response' and 'Responses' types.+  2. More interestingly, a way to automatically generate the routes from any Servant API.  This is+     accomplished using the 'HasRoutes' typeclass. You can think of this as being a less sophisticated+     version of @HasOpenApi@ from [servant-openapi3](https://hackage.haskell.org/package/servant-openapi3),+     or a more sophisticated version of @layout@ from+     [servant-server](https://hackage.haskell.org/package/servant-server).++= Motivation++Refactoring Servant API types is quite error-prone, especially when you have to move+around lots of ':<|>' and ':>'.  So it's very possible that the route structure could+change in that refactoring, /without being caught by the type-checker/.++The 'HasRoutes' class could help as a golden test - run 'getRoutes' before and after+the refactor, and if they give the same result you can be much more confident that the+refactor didn't introduce difficult bugs.++- Note that 'printRoutes' only includes the path, method and query parameters.+  For more detailed comparison, use the JSON instance of t'Routes', encode the routes to+  a file (before and after the refactoring), and use [jdiff](https://github.com/networktocode/jdiff).++Another use-case is in testing: some Haskellers use type families to modify Servant APIs, for example+to add endpoints or authorisation headers. Types are hard to test, but terms are easy. Use 'HasRoutes'+and run your tests on t'Routes'.+-}+module Servant.API.Routes+  ( -- * API routes+    Route+  , defRoute+  , renderRoute+  , Routes+  , unRoutes+  , pattern Routes++    -- * Automatic generation of routes for Servant API types++    -- | Now we can automatically generate a t'Routes' for any Servant combinator. In most cases+    -- the user should never need to implement 'HasRoutes' unless they're hacking on Servant or+    -- defining their own combinators.+  , HasRoutes (..)+  , printRoutes+  , printRoutesJSON+  , printRoutesJSONPretty++    -- * Types and helper functions++    -- ** URL paths+  , Path+  , rootPath+  , prependPathPart+  , prependCapturePart+  , prependCaptureAllPart+  , renderPath++    -- ** Request/response bodies++    -- *** Requests+  , Request+  , noRequest+  , oneRequest+  , allOfRequests++    -- *** Responses+  , Response+  , responseType+  , responseHeaders+  , Responses+  , noResponse+  , oneResponse+  , oneOfResponses++    -- ** Request/response headers+  , HeaderRep+  , mkHeaderRep++    -- ** Query parameters+  , Param+  , singleParam+  , arrayElemParam+  , flagParam+  , renderParam++    -- ** Auth schemes+  , Auth+  , basicAuth+  , customAuth+  )+where++import Data.Aeson+import Data.Aeson.Encode.Pretty+import qualified Data.Aeson.Key as AK (fromText)+import qualified Data.Aeson.Types as A (Pair)+import Data.Bifunctor (bimap)+import Data.Foldable (foldl', traverse_)+import qualified Data.Map as Map+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Encoding as TLE+import Data.Typeable+import GHC.TypeLits (KnownSymbol, Symbol)+import Lens.Micro+import Network.HTTP.Types.Method (Method)+import Servant.API+import Servant.API.Modifiers (RequiredArgument)+import "this" Servant.API.Routes.Auth+import "this" Servant.API.Routes.Header+import "this" Servant.API.Routes.Param+import "this" Servant.API.Routes.Path+import "this" Servant.API.Routes.Request+import "this" Servant.API.Routes.Response+import "this" Servant.API.Routes.Route+import "this" Servant.API.Routes.Utils++{- | To render all of an API's 'Route's as JSON, we need to identify each route by its+ path AND its method (since 2 routes can have the same path but different method).+ This newtype lets us represent this nested structure.+-}+newtype Routes = MkRoutes+  { unRoutes :: Map.Map Path (Map.Map Method Route)+  -- ^ Get the underlying 'Map.Map' of a t'Routes'.+  }+  deriving (Show, Eq)++makeRoutes :: [Route] -> Routes+makeRoutes = MkRoutes . foldl' insert mempty+  where+    insert acc r = Map.insertWith (<>) path subMap acc+      where+        path = r ^. routePath+        method = r ^. routeMethod+        subMap = Map.singleton method r++unmakeRoutes :: Routes -> [Route]+unmakeRoutes = concatMap Map.elems . Map.elems . unRoutes++{- | A smart constructor that allows us to think of a t'Routes' as simply a list of+'Route's, whereas it's actually a 'Map.Map'.+-}+pattern Routes :: [Route] -> Routes+pattern Routes rs <- (unmakeRoutes -> rs)+  where+    Routes = makeRoutes++{-# COMPLETE Routes #-}++instance ToJSON Routes where+  toJSON = object . fmap mkPair . Map.assocs . unRoutes+    where+      mkPair :: (Path, Map.Map Method Route) -> A.Pair+      mkPair = bimap (AK.fromText . renderPath) subMapToJSON++      subMapToJSON :: Map.Map Method Route -> Value+      subMapToJSON = object . fmap mkSubPair . Map.assocs++      mkSubPair (method, r) =+        let key = AK.fromText . TE.decodeUtf8 $ method+        in  key .= r++{- | Get a simple list of all the routes in an API.++One use-case, which was the original motivation for the class, is refactoring Servant APIs+to use 'NamedRoutes'. It's a fiddly, repetitive, and error-prone process, and it's very+easy to make small mistakes. A lot of these errors will be caught by the type-checker, e.g.+if the type signature of a handler function doesn't match the @ServerT@ of its API type.+However there are some errrors that wouldn't be caught by the type-checker, such as missing+out path parts.++For example, if our original API looked like++> type API =+>   "api"+>     :> "v2"+>     :> ( "users" :> Get '[JSON] [User]+>           :<|> "user" :> ReqBody '[JSON] UserData :> Post '[JSON] UserId+>        )+>+> server :: Server API+> server = listUsers :<|> createUser+>   where ...++and we refactored to++> data RefactoredAPI mode = RefactoredAPI+>   { listUsers :: mode :- "api" :> "v2" :> "users" :> Get '[JSON] [User]+>   , createUser :: mode :- "user" :> ReqBody '[JSON] UserData :> Post '[JSON] UserId+>   }+>   deriving Generic+>+> server :: Server (NamedRoutes RefactoredAPI)+> server = RefactoredAPI {listUsers, createUser}+>   where ...++Oops! We forgot the @"api" :> "v2" :>@ in the 2nd sub-endpoint. However, since the @ServerT@ type+is unaffected by adding or remove path parts, this will still compile.++However, if we use 'HasRoutes' as a sanity check:++> ghci> printRoutes @API+> GET /api/v2/users+> POST /api/v2/user+>+> ghci> printRoutes @(NamedRoutes RefactoredAPI)+> GET /api/v2/users+> POST /user++Much clearer to see the mistake. For more detailed output, use the 'ToJSON' instance:++> ghci> BL.putStrLn . encodePretty $ getRoutes @API+> [+>     {+>         "auths": [],+>         "method": "GET",+>         "params": [],+>         "path": "/api/v2/users",+>         "request_body": null,+>         "request_headers": [],+>         "response": "[User]",+>         "response_headers": []+>     },+>     {+>         "auths": [],+>         "method": "POST",+>         "params": [],+>         "path": "/api/v2/user",+>         "request_body": "UserData",+>         "request_headers": [],+>         "response": "UserId",+>         "response_headers": []+>     }+> ]+>+> ghci> BL.putStrLn . encodePretty $ getRoutes @(NamedRoutes RefactoredAPI)+> [+>     {+>         "auths": [],+>         "method": "GET",+>         "params": [],+>         "path": "/api/v2/users",+>         "request_body": null,+>         "request_headers": [],+>         "response": "[User]",+>         "response_headers": []+>     },+>     {+>         "auths": [],+>         "method": "POST",+>         "params": [],+>         "path": "/user",              -- oops!+>         "request_body": "UserData",+>         "request_headers": [],+>         "response": "UserId",+>         "response_headers": []+>     }+> ]+-}+class HasRoutes api where+  -- | Type-level list of API routes for the given API.+  --+  -- Since @TypeApplications@ is becoming pervasive, we forego @Proxy@ here in favour+  -- of @getRoutes \@API@.+  getRoutes :: [Route]++-- | Get all the routes of an API and print them to stdout. See 'renderRoute' for examples.+printRoutes :: forall api. HasRoutes api => IO ()+printRoutes = traverse_ printRoute $ getRoutes @api+  where+    printRoute = T.putStrLn . renderRoute++{- | Same as 'printRoutes`, but encode the t'Routes' as JSON before printing to stdout.+For an even prettier version, see 'printRoutesJSONPretty'.+-}+printRoutesJSON :: forall api. HasRoutes api => IO ()+printRoutesJSON =+  T.putStrLn+    . TL.toStrict+    . TLE.decodeUtf8+    . encode+    . Routes+    $ getRoutes @api++-- | Pretty-encode the t'Routes' as JSON before printing to stdout.+printRoutesJSONPretty :: forall api. HasRoutes api => IO ()+printRoutesJSONPretty =+  T.putStrLn+    . TL.toStrict+    . TLE.decodeUtf8+    . encodePretty+    . Routes+    $ getRoutes @api++instance HasRoutes EmptyAPI where+  getRoutes = mempty++instance+  ReflectMethod (method :: StdMethod) =>+  HasRoutes (NoContentVerb method)+  where+  getRoutes = pure $ defRoute method+    where+      method = reflectMethod $ Proxy @method++instance+  {-# OVERLAPPABLE #-}+  (ReflectMethod (method :: StdMethod), Typeable a) =>+  HasRoutes (Verb method status ctypes a)+  where+  getRoutes =+    pure $+      defRoute method+        & routeResponse .~ response+    where+      method = reflectMethod $ Proxy @method+      response = oneResponse @a++instance+  {-# OVERLAPPING #-}+  ( ReflectMethod (method :: StdMethod)+  , GetHeaderReps hs+  , Typeable a+  ) =>+  HasRoutes (Verb method status ctypes (Headers hs a))+  where+  getRoutes =+    pure $+      defRoute method+        & routeResponse .~ response+    where+      method = reflectMethod $ Proxy @method+      response = oneResponse @(Headers hs a)++#if MIN_VERSION_servant(0,18,1)+instance+  {-# OVERLAPPING #-}+  (ReflectMethod (method :: StdMethod)) =>+  HasRoutes (UVerb method ctypes '[])+  where+  getRoutes = pure $ defRoute method+    where+      method = reflectMethod $ Proxy @method++instance+  {-# OVERLAPPING #-}+  (ReflectMethod (method :: StdMethod), Typeable a) =>+  HasRoutes (UVerb method ctypes '[a])+  where+  getRoutes =+    pure $+      defRoute method+        & routeResponse .~ response+    where+      method = reflectMethod $ Proxy @method+      response = oneResponse @a++instance+  (ReflectMethod (method :: StdMethod), AllHasResponse as, Unique as) =>+  HasRoutes (UVerb method ctypes as)+  where+  getRoutes =+    pure $+      defRoute method+        & routeResponse .~ response+    where+      method = reflectMethod $ Proxy @method+      response = oneOfResponses @as+#endif++instance (HasRoutes l, HasRoutes r) => HasRoutes (l :<|> r) where+  getRoutes = getRoutes @l <> getRoutes @r++instance (KnownSymbol path, HasRoutes api) => HasRoutes (path :> api) where+  getRoutes = getRoutes @api <&> routePath %~ prependPathPart path+    where+      path = knownSymbolT @path++instance+  (KnownSymbol capture, Typeable a, HasRoutes api) =>+  HasRoutes (Capture' mods capture a :> api)+  where+  getRoutes = getRoutes @api <&> routePath %~ prependCapturePart @a capture+    where+      capture = knownSymbolT @capture++instance+  (KnownSymbol capture, Typeable a, HasRoutes api) =>+  HasRoutes (CaptureAll capture a :> api)+  where+  getRoutes = getRoutes @api <&> routePath %~ prependCaptureAllPart @a capture+    where+      capture = knownSymbolT @capture++instance+  (KnownSymbol sym, Typeable (RequiredArgument mods a), HasRoutes api) =>+  HasRoutes (QueryParam' mods sym a :> api)+  where+  getRoutes = getRoutes @api <&> routeParams `add` param+    where+      param = singleParam @sym @(RequiredArgument mods a)++instance+  (KnownSymbol sym, Typeable a, HasRoutes api) =>+  HasRoutes (QueryParams sym a :> api)+  where+  getRoutes = getRoutes @api <&> routeParams `add` param+    where+      param = arrayElemParam @sym @a++#if MIN_VERSION_servant(0,19,0)+instance (HasRoutes (ToServantApi routes)) => HasRoutes (NamedRoutes routes) where+  getRoutes = getRoutes @(ToServantApi routes)+#endif++instance (KnownSymbol sym, HasRoutes api) => HasRoutes (QueryFlag sym :> api) where+  getRoutes = getRoutes @api <&> routeParams `add` param+    where+      param = flagParam @sym++instance (HasRoutes api, Typeable a) => HasRoutes (ReqBody' mods list a :> api) where+  getRoutes = getRoutes @api <&> routeRequestBody <>~ reqBody+    where+      reqBody = oneRequest @a++instance (HasRoutes api) => HasRoutes (Vault :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api) => HasRoutes (HttpVersion :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api, KnownSymbol realm) => HasRoutes (BasicAuth realm usr :> api) where+  getRoutes = getRoutes @api <&> routeAuths `add` auth+    where+      auth = basicAuth @realm++instance (HasRoutes api) => HasRoutes (Description sym :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api) => HasRoutes (Summary sym :> api) where+  getRoutes = getRoutes @api++instance+  (HasRoutes api, KnownSymbol tag) =>+  HasRoutes (AuthProtect (tag :: Symbol) :> api)+  where+  getRoutes = getRoutes @api <&> routeAuths `add` auth+    where+      auth = customAuth @tag++instance+  (HasRoutes api, KnownSymbol sym, Typeable (RequiredArgument mods a)) =>+  HasRoutes (Header' mods sym a :> api)+  where+  getRoutes = getRoutes @api <&> routeRequestHeaders `add` header+    where+      header = mkHeaderRep @sym @(RequiredArgument mods a)++instance (HasRoutes api) => HasRoutes (Fragment v :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api) => HasRoutes (IsSecure :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api) => HasRoutes (RemoteHost :> api) where+  getRoutes = getRoutes @api++instance (HasRoutes api, Typeable a) => HasRoutes (StreamBody' mods framing ct a :> api) where+  getRoutes = getRoutes @api <&> routeRequestBody .~ reqBody+    where+      reqBody = oneRequest @a++instance (HasRoutes api) => HasRoutes (WithNamedContext name subContext api) where+  getRoutes = getRoutes @api++instance+  (ReflectMethod (method :: StdMethod), Typeable a) =>+  HasRoutes (Stream method status framing ctype a)+  where+  getRoutes =+    pure $+      defRoute method+        & routeResponse .~ response+    where+      method = reflectMethod $ Proxy @method+      response = oneResponse @a
+ src/Servant/API/Routes/Auth.hs view
@@ -0,0 +1,47 @@+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Auth+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Here we define a very very basic type to represent authentication schemes.+-}+module Servant.API.Routes.Auth+  ( Auth+  , basicAuth+  , customAuth+  )+where++import GHC.TypeLits (KnownSymbol)+import "this" Servant.API.Routes.Internal.Auth+import "this" Servant.API.Routes.Utils++{- | Create a term-level representation of a \"Basic" authentication scheme.++For example:++> ghci> toJSON $ basicAuth @"user"+> String "Basic user"+-}+basicAuth ::+  forall realm.+  KnownSymbol realm =>+  Auth+basicAuth = Basic $ knownSymbolT @realm++{- | Create a term-level representation of a \"Custom" authentication scheme, i.e. one that+corresponds to Servant's 'Servant.API.AuthProtect' combinator.++For example:++> ghci> toJSON $ customAuth @"OnlyAdminUsers"+> String "OnlyAdminUsers"+-}+customAuth ::+  forall tag.+  KnownSymbol tag =>+  Auth+customAuth = Custom $ knownSymbolT @tag
+ src/Servant/API/Routes/Header.hs view
@@ -0,0 +1,16 @@+{- |+Module      : Servant.API.Routes.Header+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Simple representation of HTTP headers.+-}+module Servant.API.Routes.Header+  ( HeaderRep+  , GetHeaderReps (..)+  , mkHeaderRep+  )+where++import "this" Servant.API.Routes.Internal.Header
+ src/Servant/API/Routes/Internal/Auth.hs view
@@ -0,0 +1,33 @@+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Auth+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Auth+  ( Auth (..)+  )+where++import Data.Aeson+import qualified Data.Text as T++{- | There are 2 variants:++- \"Basic" authentication: corresponds to the 'Servant.API.BasicAuth' type. Construct with 'Servant.API.Routes.Auth.basicAuth'.+- \"Custom" authentication: corresponds to the 'Servant.API.AuthProtect' type. Construct with 'Servant.API.Routes.Auth.customAuth'.+-}+data Auth+  = Basic T.Text+  | Custom T.Text+  deriving (Show, Eq, Ord)++instance ToJSON Auth where+  toJSON =+    toJSON @T.Text . \case+      Basic realm -> "Basic " <> realm+      Custom tag -> tag
+ src/Servant/API/Routes/Internal/Header.hs view
@@ -0,0 +1,72 @@+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Header+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Header+  ( HeaderRep (..)+  , mkHeaderRep+  , GetHeaderReps (..)+  )+where++import Data.Aeson+import Data.Kind (Type)+import Data.Text+import Data.Typeable+import GHC.TypeLits+import Servant.API+import "this" Servant.API.Routes.Utils++-- | Convenience function to construct a 'HeaderRep' from @sym :: 'Symbol'@ and @a :: Type'@.+mkHeaderRep ::+  forall sym a.+  (KnownSymbol sym, Typeable a) =>+  HeaderRep+mkHeaderRep =+  HeaderRep+    { _hName = knownSymbolT @sym+    , _hType = typeRepOf @a+    }++{- | Simple term-level representation of a 'Servant.API.Header.Header'.++A type-level @'Servant.API.Header.Header' (sym :: 'GHC.TypeLits.Symbol') typ@ should correspond to+@'HeaderRep' { _hName = str, _hType =  typRep }@, where @str@ is the term-level equivalent+of @sym@ and @typRep@ is the term-level representation of @typ@.+-}+data HeaderRep = HeaderRep+  { _hName :: Text+  , _hType :: TypeRep+  }+  deriving (Show, Eq, Ord)++instance ToJSON HeaderRep where+  toJSON HeaderRep {..} =+    object+      [ "name" .= _hName+      , "type" .= typeRepToJSON _hType+      ]++{- | Utility class to let us get a value-level list of 'HeaderRep's from a+type-level list of 'Servant.API.Header.Header's. See the implementation of+@'Servant.API.Route.HasRoutes' ('Verb' method status ctypes ('Headers' hs a))@ for an example.+-}+class GetHeaderReps (hs :: [Type]) where+  getHeaderReps :: [HeaderRep]++instance GetHeaderReps '[] where+  getHeaderReps = []++instance+  (GetHeaderReps rest, KnownSymbol h, Typeable v) =>+  GetHeaderReps (Header h v ': rest)+  where+  getHeaderReps = header : getHeaderReps @rest+    where+      header = mkHeaderRep @h @v
+ src/Servant/API/Routes/Internal/Param.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE DeriveAnyClass #-}++{- |+Module      : Servant.API.Routes.Internal.Param+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Param+  ( Param (..)+  )+where++import Data.Aeson+import Data.Function (on)+import GHC.Generics+import qualified Servant.Links as S++{- | Newtype wrapper around servant's 'S.Param' so we can define a sensible+'Eq' instance for it.+-}+newtype Param = Param+  { unParam :: S.Param+  }+  deriving (Show) via S.Param++instance Eq Param where+  (==) = eq `on` unParam+    where+      S.SingleParam name1 rep1 `eq` S.SingleParam name2 rep2 =+        name1 == name2 && rep1 == rep2+      S.ArrayElemParam name1 rep1 `eq` S.ArrayElemParam name2 rep2 =+        name1 == name2 && rep1 == rep2+      S.FlagParam name1 `eq` S.FlagParam name2 =+        name1 == name2+      _ `eq` _ = False++instance Ord Param where+  compare = comp `on` unParam+    where+      S.SingleParam name1 rep1 `comp` S.SingleParam name2 rep2 =+        name1 `compare` name2 <> rep1 `compare` rep2+      S.ArrayElemParam name1 rep1 `comp` S.ArrayElemParam name2 rep2 =+        name1 `compare` name2 <> rep1 `compare` rep2+      S.FlagParam name1 `comp` S.FlagParam name2 =+        name1 `compare` name2+      S.SingleParam {} `comp` _ = LT+      _ `comp` S.SingleParam {} = LT+      S.ArrayElemParam {} `comp` _ = LT+      _ `comp` S.ArrayElemParam {} = LT++data ParamType+  = SingleParam+  | ArrayElemParam+  | FlagParam+  deriving (Show, Eq, Enum, Bounded, Generic)+  deriving (ToJSON)++instance ToJSON Param where+  toJSON (Param p) =+    object $ case p of+      S.SingleParam name rep ->+        withType SingleParam ["name" .= name, "param_type" .= rep]+      S.ArrayElemParam name rep ->+        withType ArrayElemParam ["name" .= name, "param_type" .= rep]+      S.FlagParam name ->+        withType FlagParam ["name" .= name]+    where+      withType t ps = ("type" .= t) : ps
+ src/Servant/API/Routes/Internal/Path.hs view
@@ -0,0 +1,65 @@+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Path+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Path+  ( Path (..)+  , renderPath+  , pathSeparator+  , PathPart (..)+  )+where++import Data.Aeson+import Data.String+import qualified Data.Text as T+import Data.Typeable++-- | A segment of an API path (between @"/"@s).+data PathPart+  = -- | Just a plain path part, e.g. @"api/"@.+    StringPart T.Text+  | -- | Capture a path part as a variable.+    CapturePart T.Text TypeRep+  | -- | Capture all path part as a list of variables.+    CaptureAllPart T.Text TypeRep+  deriving (Eq, Ord)++instance IsString PathPart where+  fromString = StringPart . T.pack++instance Show PathPart where+  show = T.unpack . renderPathPart++-- | Pretty-print a path part.+renderPathPart :: PathPart -> T.Text+renderPathPart = \case+  StringPart t -> t+  CapturePart _ typ -> "<" <> T.pack (show typ) <> ">"+  CaptureAllPart _ typ -> "<[" <> T.pack (show typ) <> "]>"++-- | Standard path separator @"/"@.+pathSeparator :: T.Text+pathSeparator = "/"++-- | Simple representation of a URL path.+newtype Path = Path+  { unPath :: [PathPart]+  }+  deriving (Eq, Ord) via [PathPart]++instance Show Path where+  show = T.unpack . renderPath++instance ToJSON Path where+  toJSON = toJSON . renderPath++-- | Pretty-print a path, including the leading @/@.+renderPath :: Path -> T.Text+renderPath = (pathSeparator <>) . T.intercalate pathSeparator . fmap renderPathPart . unPath
+ src/Servant/API/Routes/Internal/Request.hs view
@@ -0,0 +1,75 @@+{-# LANGUAGE TemplateHaskell #-}+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Request+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Request+  ( Request (..)+  , unRequest+  , AllTypeable (..)+  )+where++import Data.Aeson+import Data.Function (on)+import Data.Kind (Type)+import Data.List (nub, sort)+import Data.Typeable+import Lens.Micro.TH+import "this" Servant.API.Routes.Internal.Some as S+import "this" Servant.API.Routes.Utils++{- | A representation of the request /body/(s) that a Servant endpoint expects.++Under the hood, 'Request' is a @'Some' 'TypeRep'@.+This allows for the possibility that an endpoint might expect the request body+to parse as several different types (multiple 'Servant.API.ReqBody''s).++Note that this type doesn't include any information about the headers that an+endpoint expects, since those are independent of the request body.+-}+newtype Request = Request {_unRequest :: Some TypeRep}+  deriving (Show) via Some TypeRep++makeLenses ''Request++instance ToJSON Request where+  toJSON = someToJSONAs typeRepToJSON "all_of" . _unRequest++instance Eq Request where+  (==) = eqSome ((==) `on` (sort . nub)) `on` _unRequest++instance Semigroup Request where+  Request b1 <> Request b2 = Request (appendSome (:) (flip (:)) b1 b2)++instance Monoid Request where+  mempty = Request S.None++{- | This class does 2 things:++- It lets us get a term-level list of 'TypeRep's from a type-level list of types, all of+  which have 'Typeable' instances.+- More impressively, its instances enforce that 'typeReps' will only type-check for type-level+  lists of length 2 or more. This is because 'AllTypeable' will only ever be used by+  'Servant.API.Routes.Request.allOfRequests', which is the only way to construct a+  'Many' @'Request' and thus lets us enforce the invariant that its list arguments will always+  have more than 1 element. This lets us make sure that there's only ever one way to represent a list of+  'TypeRep's using 'Request'.++  Of course, someone might import this Internal module and define a @'Typeable' a => 'AllTypeable' '[a]@+  instance. Don't do that.+-}+class AllTypeable (as :: [Type]) where+  typeReps :: [TypeRep]++instance (Typeable a, Typeable b) => AllTypeable '[a, b] where+  typeReps = [typeRepOf @a, typeRepOf @b]++instance (Typeable a, AllTypeable (b ': c ': as)) => AllTypeable (a ': b ': c ': as) where+  typeReps = typeRepOf @a : typeReps @(b ': c ': as)
+ src/Servant/API/Routes/Internal/Response.hs view
@@ -0,0 +1,122 @@+{-# LANGUAGE TemplateHaskell #-}+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Response+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Response+  ( Responses (..)+  , unResponses+  , Response (..)+  , responseType+  , responseHeaders+  , HasResponse (..)+  , AllHasResponse (..)+  )+where++import Data.Aeson+import Data.Function (on)+import Data.Kind (Type)+import Data.List (nub, sort)+import qualified Data.Set as Set+import Data.Typeable+import Lens.Micro+import Lens.Micro.TH+import Servant.API hiding (getResponse)+import "this" Servant.API.Routes.Internal.Header+import "this" Servant.API.Routes.Internal.Some as S+import "this" Servant.API.Routes.Utils++{- | A representation of /one/ possible response that a Servant endpoint+can return.++Currently, the only situation in which multiple 'Response's can be returned+is using the 'UVerb' combinator. This bundles response /types/ together with+response 'Servant.API.Header.Header's, so we do the same here.+-}+data Response = Response+  { _responseType :: TypeRep+  , _responseHeaders :: Set.Set HeaderRep+  }+  deriving (Show, Eq, Ord)++makeLenses ''Response++instance ToJSON Response where+  toJSON Response {..} =+    object+      [ "type" .= typeRepToJSON _responseType+      , "headers" .= _responseHeaders+      ]++{- | Get a term-level response from a type-level argument. This encodes the argument(s)+of a 'Verb' or 'UVerb'.++Similar to 'Typeable', but also get the response 'Servant.API.Header.Header's.+-}+class HasResponse a where+  getResponse :: Response++instance {-# OVERLAPPABLE #-} Typeable a => HasResponse a where+  getResponse = Response (typeRepOf @a) mempty++instance {-# OVERLAPPING #-} (HasResponse a, GetHeaderReps hs) => HasResponse (Headers hs a) where+  getResponse =+    getResponse @a+      & responseHeaders <>~ Set.fromList (getHeaderReps @hs)++{- | Witness that all members of a type-level list are instances of 'HasResponse'.++This class does 2 things:++- It lets us get a term-level list of 'Response's from a type-level list of types, all of+  which have 'HasResponse' instances.+- More impressively, its instances enforce that 'getResponses' will only type-check for type-level+  lists of length 2 or more. This is because 'AllHasResponse' will only ever be used by+  'Servant.API.Routes.Response.oneOfResponses', which is the only way to construct a+  'Many' @'Response' and thus lets us enforce the invariant that its list arguments will always+  have more than 1 element. This lets us make sure that there's only ever one way to represent a list of+  'Response's using 'Responses'.++  Of course, someone might import this Internal module and define a @'HasResponse' a => 'AllHasResponse' '[a]@+  instance. Don't do that.+-}+class AllHasResponse (as :: [Type]) where+  getResponses :: [Response]++instance (HasResponse a, HasResponse b) => AllHasResponse '[a, b] where+  getResponses = [getResponse @a, getResponse @b]++instance (HasResponse a, AllHasResponse (b ': c ': as)) => AllHasResponse (a ': b ': c ': as) where+  getResponses = getResponse @a : getResponses @(b ': c ': as)++{- | A representation of the response(s) that a Servant endpoint can return.++Under the hood, 'Responses' is a @'Some' 'Response'@.+This allows for the possibility that an endpoint might return one of several+responses, via 'UVerb'.++Note that a 'Response' consists of a return body type, /as well as/ the return headers.+-}+newtype Responses = Responses {_unResponses :: Some Response}+  deriving (Show) via Some Response++makeLenses ''Responses++instance Eq Responses where+  (==) = eqSome ((==) `on` (sort . nub)) `on` _unResponses++instance Semigroup Responses where+  Responses b1 <> Responses b2 = Responses (appendSome (:) (flip (:)) b1 b2)++instance Monoid Responses where+  mempty = Responses S.None++instance ToJSON Responses where+  toJSON = someToJSONAs toJSON "one_of" . _unResponses
+ src/Servant/API/Routes/Internal/Route.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE TemplateHaskell #-}++{- |+Module      : Servant.API.Routes.Internal.Route+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.+-}+module Servant.API.Routes.Internal.Route+  ( -- * API routes+    Route (..)++    -- * Optics #optics#+  , routeMethod+  , routePath+  , routeParams+  , routeRequestHeaders+  , routeRequestBody+  , routeResponse+  , routeAuths+  )+where++import Data.Aeson+import Data.Function (on)+import qualified Data.Set as Set+import qualified Data.Text.Encoding as TE+import Lens.Micro.TH+import Network.HTTP.Types.Method (Method)+import "this" Servant.API.Routes.Auth+import "this" Servant.API.Routes.Header+import "this" Servant.API.Routes.Internal.Request+import "this" Servant.API.Routes.Internal.Response+import "this" Servant.API.Routes.Param+import "this" Servant.API.Routes.Path++-- | A simple representation of a single endpoint of an API.+data Route = Route+  { _routeMethod :: Method+  , _routePath :: Path+  , _routeParams :: Set.Set Param+  , _routeRequestHeaders :: Set.Set HeaderRep+  , _routeRequestBody :: Request+  , _routeResponse :: Responses+  , _routeAuths :: Set.Set Auth+  }+  deriving (Show, Eq)++makeLenses ''Route++instance Ord Route where+  compare = compare `on` \Route {..} -> (_routePath, _routeMethod)++instance ToJSON Route where+  toJSON Route {..} =+    object+      [ "method" .= TE.decodeUtf8 _routeMethod+      , "path" .= _routePath+      , "params" .= _routeParams+      , "request_headers" .= _routeRequestHeaders+      , "request_body" .= _routeRequestBody+      , "response" .= _routeResponse+      , "auths" .= _routeAuths+      ]
+ src/Servant/API/Routes/Internal/Some.hs view
@@ -0,0 +1,129 @@+{-# LANGUAGE DeriveTraversable #-}+{-# OPTIONS_HADDOCK not-home #-}++{- |+Module      : Servant.API.Routes.Internal.Some+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Internal module, subject to change.++For both requests and responses, we want to represent three situations, depending on the number+of /X/ present: 0, 1, or many.++For example, if multiple 'Servant.API.ReqBody''s are chained together with @:>@, the resulting+type's @HasServer@ instance would try to parse the request body as all of the relevant types. In this+case the 'Servant.API.Routes.Internal.Route._routeRequestBody' field needs to be able to contain several+'Data.Typeable.TypeRep's as a conjunction (AND).++On the other hand, the 'Servant.API.UVerb' combinator lets us represent an endpoint that may return one of+several types: hence the 'Servant.API.Routes.Internal.Route._routeResponse' field needs to be able to contain+several possible responses as a disjunction (OR).++The 'Some' type lets us represent both of these situations.++However we need to abstract over the type contained in 'Some', since we need to represent different+data in those 2 different situations, because of the way that Servant represents them:++  - for requests, different 'Servant.API.ReqBody's and 'Servant.API.Header.Header's are independent.+    Therefore in 'Servant.API.Routes.Route.Route', for requests we have+    @'Servant.API.Routes.Internal.Route._routeRequestHeaders' :: 'Servant.API.Routes.Header.HeaderRep'@ and+    @'Servant.API.Routes.Internal.Route._routeRequestBody' ~ 'Some' 'Data.Typeable.TypeRep'@.+  - for responses, multiple response options are represented using 'Servant.API.UVerb', which bundles response+    types and headers together. Therefore for responses we have just one field,+    @'Servant.API.Routes.Internal.Route._routeResponse' ~ 'Some' ('Data.Typeable.TypeRep', ['Servant.API.Routes.Header.HeaderRep'])@.+-}+module Servant.API.Routes.Internal.Some+  ( Some (..)+  , toList+  , fromList+  , eqSome+  , appendSome+  , someToJSONAs+  )+where++import Data.Aeson+import qualified Data.Aeson.Key as AK (fromText)+import qualified Data.Foldable as Fold+import qualified Data.Text as T++-- | Simple ADT which codifies whether a list contains 0, 1, or many elements.+data Some a+  = None+  | One a+  | -- | Invariant: list needs to have length > 1+    --+    -- Whether or not order is important is left up to the user. Therefore we define no instances of+    -- 'Eq', 'Ord', 'Semigroup' or any other class that would involve comparing or combining this list.+    Many [a]+  deriving (Show, Functor, Foldable, Traversable)++{- | Compare 2 'Some's for equality, given a way to compare lists in the 'Many' constructor.++Use this to implement `Eq` instances for newtypes around 'Some'.+-}+eqSome :: forall a. Eq a => ([a] -> [a] -> Bool) -> Some a -> Some a -> Bool+eqSome eqList = eq+  where+    None `eq` None = True+    One t1 `eq` One t2 = t1 == t2+    Many ts1 `eq` Many ts2 = ts1 `eqList` ts2+    _ `eq` _ = False++{- | Combine 2 'Some's, given a way to combine single elements with lists in the+@'Servant.API.Routes.Internal.Some.One' <> 'Many'@ cases.++Use this to implement `Semigroup` instances for newtypes around 'Some'.+-}+appendSome ::+  forall a.+  (a -> [a] -> [a]) ->+  ([a] -> a -> [a]) ->+  Some a ->+  Some a ->+  Some a+appendSome cons' snoc' = app+  where+    None `app` x = x+    x `app` None = x+    One t1 `app` One t2 = Many [t1, t2]+    One t `app` Many ts = Many (t `cons'` ts)+    Many ts `app` One t = Many (ts `snoc'` t)+    Many ts1 `app` Many ts2 = Many (ts1 <> ts2)++-- | Convert a 'Some' to a list. Inverse of 'fromList'.+toList :: Some a -> [a]+toList = Fold.toList++{- | Convert a list of @a@s to a 'Some'. Inverse of 'toList'.++This maintains the invariant that the argument of 'Many' has to be of length > 1.+-}+fromList :: [a] -> Some a+fromList = \case+  [] -> None+  [tRep] -> One tRep+  tReps -> Many tReps++{- | Represent a 'Some' as a JSON 'Value'.++Use this to implement `ToJSON` instances for newtypes around 'Some'.++This choice of representation is opinionated, and some may disagree.++Given a function @f@ to convert an @a@ to JSON, and a @label@:++@+'None' -> null+'Servant.API.Routes.Internal.Some.One' a -> f a+'Many' list -> { label: map f list }+@+-}+someToJSONAs :: (a -> Value) -> T.Text -> Some a -> Value+someToJSONAs aToJSON lbl = \case+  None -> Null+  One tRep -> aToJSON tRep+  Many tReps ->+    object [AK.fromText lbl .= fmap aToJSON tReps]
+ src/Servant/API/Routes/Param.hs view
@@ -0,0 +1,50 @@+{- |+Module      : Servant.API.Routes.Param+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Simple representation of HTTP query params+-}+module Servant.API.Routes.Param+  ( Param+  , singleParam+  , arrayElemParam+  , flagParam+  , renderParam+  )+where++import qualified Data.Text as T+import Data.Typeable+import GHC.TypeLits+import "this" Servant.API.Routes.Internal.Param+import "this" Servant.API.Routes.Utils+import qualified Servant.Links as S++-- | Create a 'S.SingleParam' from a 'Symbol' and a 'TypeRep' via 'Typeable'.+singleParam :: forall s a. (KnownSymbol s, Typeable a) => Param+singleParam = Param (S.SingleParam name rep)+  where+    rep = showTypeRep @a+    name = symbolVal $ Proxy @s++-- | Create an 'S.ArrayParam' from a 'Symbol' and a 'TypeRep' via 'Typeable'.+arrayElemParam :: forall s a. (KnownSymbol s, Typeable a) => Param+arrayElemParam = Param (S.ArrayElemParam name rep)+  where+    rep = showTypeRep @a+    name = symbolVal $ Proxy @s++-- | Create a 'S.FlagParam' from a 'Symbol'.+flagParam :: forall s. (KnownSymbol s) => Param+flagParam = Param (S.FlagParam name)+  where+    name = symbolVal $ Proxy @s++-- | Pretty-print a 'Param'. Used by 'Servant.API.Routes.renderRoute'.+renderParam :: Param -> T.Text+renderParam (Param param) = case param of+  S.SingleParam var typ -> T.pack var <> "=<" <> typ <> ">"+  S.ArrayElemParam var typ -> T.pack var <> "=<[" <> typ <> "]>"+  S.FlagParam var -> T.pack var
+ src/Servant/API/Routes/Path.hs view
@@ -0,0 +1,68 @@+{- |+Module      : Servant.API.Routes.Path+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Simple representation of URL paths.+-}+module Servant.API.Routes.Path+  ( Path+  , prependPathPart+  , prependCapturePart+  , prependCaptureAllPart+  , renderPath+  , rootPath+  )+where++import qualified Data.Text as T+import Data.Typeable+import "this" Servant.API.Routes.Internal.Path+import "this" Servant.API.Routes.Utils++-- | @"/"@+rootPath :: Path+rootPath = Path []++{- | Prepend a simple text path part to an API path.++For example, @prependPathPart "api"@ will transform @\/v2\/users@ to @\/api\/v2\/users@.+-}+prependPathPart :: T.Text -> Path -> Path+prependPathPart part (Path parts) =+  Path (splitParts <> parts)+  where+    splitParts = fmap StringPart . filter (not . T.null) $ T.splitOn pathSeparator part++{- | Prepend a capture path part of a given type to an API path.+Equivalent to @'Servant.API.Capture' name a :>@.++For example, @prependCapturePart \@Int "id"@ will transform @\/detail@ to @\/\<Int>\/detail@.+-}+prependCapturePart ::+  forall a.+  Typeable a =>+  T.Text ->+  Path ->+  Path+prependCapturePart name (Path parts) =+  Path (capture : parts)+  where+    capture = CapturePart name $ typeRepOf @a++{- | Prepend a capture-all path part of a given type to an API path.+Equivalent to @'Servant.API.CaptureAll' name a :>@.++For example, @prependCaptureAllPart \@Int "id"@ will transform @\/detail@ to @\/\<[Int]>\/detail@.+-}+prependCaptureAllPart ::+  forall a.+  Typeable a =>+  T.Text ->+  Path ->+  Path+prependCaptureAllPart name (Path parts) =+  Path (capture : parts)+  where+    capture = CaptureAllPart name $ typeRepOf @a
+ src/Servant/API/Routes/Request.hs view
@@ -0,0 +1,49 @@+{- |+Module      : Servant.API.Routes.Request+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Term-level representation of the request bodies that Servant endpoints expect.+-}+module Servant.API.Routes.Request+  ( Request+  , unRequest+  , noRequest+  , oneRequest+  , allOfRequests+  , requests+  )+where++import Data.Typeable+import Lens.Micro+import "this" Servant.API.Routes.Internal.Request+import "this" Servant.API.Routes.Internal.Some+import "this" Servant.API.Routes.Utils++-- | The endpoint doesn't expect a request body.+noRequest :: Request+noRequest = Request None++{- | The request body can only be of one type. Equivalent to a single+@'Servant.API.ReqBody' _ a@.+-}+oneRequest ::+  forall a.+  Typeable a =>+  Request+oneRequest = Request . One $ typeRepOf @a++{- | The endpoint expects the request body to be parsed as multiple (>1) types.+Equivalent to multiple 'Servant.API.ReqBody's chained with @:>@.+-}+allOfRequests ::+  forall as.+  AllTypeable as =>+  Request+allOfRequests = Request . Many $ typeReps @as++-- | Convenience optic to traverse over all the 'TypeRep's within a 'Request'.+requests :: Traversal' Request TypeRep+requests = unRequest . traversed
+ src/Servant/API/Routes/Response.hs view
@@ -0,0 +1,50 @@+{- |+Module      : Servant.API.Routes.Response+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Term-level representation of the responses that Servant endpoints can return.+-}+module Servant.API.Routes.Response+  ( Responses+  , unResponses+  , noResponse+  , oneResponse+  , oneOfResponses+  , Response+  , responseType+  , responseHeaders+  , responses+  , HasResponse (..)+  , AllHasResponse (..)+  )+where++import Lens.Micro+import "this" Servant.API.Routes.Internal.Response+import "this" Servant.API.Routes.Internal.Some++-- | The endpoint will not return a response.+noResponse :: Responses+noResponse = Responses None++-- | There is only one possible response. Equivalent to a single @'Servant.API.ReqBody' _ a@.+oneResponse ::+  forall a.+  HasResponse a =>+  Responses+oneResponse = Responses . One $ getResponse @a++{- | The endpoint may return one of multiple multiple (>1) responses.+Equivalent to a 'Servant.API.UVerb's with more than one type.+-}+oneOfResponses ::+  forall as.+  AllHasResponse as =>+  Responses+oneOfResponses = Responses . Many $ getResponses @as++-- | Convenience optic to traverse over all the 'Response's within a 'Responses'.+responses :: Traversal' Responses Response+responses = unResponses . traversed
+ src/Servant/API/Routes/Route.hs view
@@ -0,0 +1,87 @@+{- |+Module      : Servant.API.Routes.Route+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Simple term-level representation of Servant API endpoints.+-}+module Servant.API.Routes.Route+  ( -- * API routes++    -- | The 'Route' type is not sophisticated, and its internals are hidden.+    -- Create 'Route's using 'Servant.API.Routes.Route.defRoute', and update its fields+    -- using the provided [lenses](#g:optics).+    Route+  , defRoute+  , renderRoute++    -- * Optics #optics#+  , routeMethod+  , routePath+  , routeParams+  , routeRequestHeaders+  , routeRequestBody+  , routeResponse+  , routeAuths+  , add+  )+where++import qualified Data.Set as Set+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Lens.Micro+import Network.HTTP.Types.Method (Method)+import "this" Servant.API.Routes.Internal.Route+import "this" Servant.API.Routes.Param+import "this" Servant.API.Routes.Path+import "this" Servant.API.Routes.Request+import "this" Servant.API.Routes.Response++{- | Given a REST 'Method', create a default 'Route': root path (@"/"@) with no params,+headers, body, auths, or response.+-}+defRoute :: Method -> Route+defRoute method =+  Route+    { _routeMethod = method+    , _routePath = rootPath+    , _routeParams = Set.empty+    , _routeRequestHeaders = mempty+    , _routeRequestBody = noRequest+    , _routeResponse = noResponse+    , _routeAuths = mempty+    }++{- | Pretty-print a 'Route'. Note that the output is minimal and doesn't contain all the information+contained in a 'Route'. For full output, use the 'Data.Aeson.ToJSON' instance.++> ghci> renderRoute $ defRoute \"POST\"+> "POST /"+> ghci> :{+> ghci| renderRoute $+> ghci|   defRoute \"POST\"+> ghci|     & routePath %~ prependPathPart "api/v2"+> ghci|     & routeParams .~ [singleParam @"p1" @T.Text, flagParam @"flag", arrayElemParam @"p2s" @(Maybe Int)]+> ghci| :}+> "POST /api/v2?p1=<Text>&flag&p2s=<[Maybe Int]>"+-}+renderRoute :: Route -> T.Text+renderRoute Route {..} =+  mconcat+    [ method+    , " "+    , path+    , params+    ]+  where+    method = TE.decodeUtf8 _routeMethod+    path = renderPath _routePath+    params =+      if null _routeParams+        then ""+        else "?" <> T.intercalate "&" (renderParam <$> Set.toList _routeParams)++add :: Ord a => ASetter s t (Set.Set a) (Set.Set a) -> a -> s -> t+add setter = over setter . Set.insert
+ src/Servant/API/Routes/Utils.hs view
@@ -0,0 +1,37 @@+{- |+Module      : Servant.API.Routes.Utils+Copyright   : (c) Frederick Pringle, 2025+License     : BSD-3-Clause+Maintainer  : freddyjepringle@gmail.com++Common useful functions.+-}+module Servant.API.Routes.Utils+  ( knownSymbolT+  , typeRepToJSON+  , showTypeRep+  , typeRepOf+  )+where++import Data.Aeson+import Data.Kind+import qualified Data.Text as T+import Data.Typeable+import GHC.TypeLits++-- | Get the term-level equivalent of a 'Symbol' as a 'T.Text'.+knownSymbolT :: forall name. KnownSymbol name => T.Text+knownSymbolT = T.pack . symbolVal $ Proxy @name++-- | Convert a 'TypeRep' to a JSON 'Value' via its 'Show' instance.+typeRepToJSON :: TypeRep -> Value+typeRepToJSON = toJSON . show @TypeRep++-- | Get the 'TypeRep' of a 'Typeable' type without having to mess around with 'Proxy'.+typeRepOf :: forall a. Typeable a => TypeRep+typeRepOf = typeRep $ Proxy @a++-- | Get the 'TypeRep' of a 'Typeable' type as a 'T.Text'.+showTypeRep :: forall (a :: Type). Typeable a => T.Text+showTypeRep = T.pack . show $ typeRepOf @a
+ test/Servant/API/Routes/HeaderSpec.hs view
@@ -0,0 +1,32 @@+module Servant.API.Routes.HeaderSpec+  ( spec+  , sampleReps+  )+where++import Servant.API (Header)+import Servant.API.Routes.Header+import Servant.API.Routes.Internal.Header+import Servant.API.Routes.Util+import Test.Hspec as H++spec :: Spec+spec = do+  describe "mkHeaderRep" $ do+    it "should work" $ do+      mkHeaderRep @"sym" @Int `shouldBe` HeaderRep "sym" intTypeRep++  describe "GetheaderReps" $ do+    it "should return an empty list for an empty type-level list" $+      getHeaderReps @'[] `shouldBe` []+    it "should recurse properly" $+      sampleReps `shouldBe` HeaderRep "h1" intTypeRep : getHeaderReps @'[H2, H3]++type H1 = Header "h1" Int++type H2 = Header "h2" Char++type H3 = Header "h3" [Integer]++sampleReps :: [HeaderRep]+sampleReps = getHeaderReps @'[H1, H2, H3]
+ test/Servant/API/Routes/ParamSpec.hs view
@@ -0,0 +1,25 @@+module Servant.API.Routes.ParamSpec where++import qualified Data.Text as T+import Servant.API.Routes.Param+import Test.Hspec as H++sing, arrayElem, flag :: Param+sing = singleParam @"sing_sym" @Int+arrayElem = arrayElemParam @"array_sym" @Int+flag = flagParam @"flag_sym"++singExpected, arrayElemExpected, flagExpected :: T.Text+singExpected = "sing_sym=<Int>"+arrayElemExpected = "array_sym=<[Int]>"+flagExpected = "flag_sym"++spec :: Spec+spec = do+  describe "renderParam" $ do+    it "should render singleParam correctly" $ do+      renderParam sing `shouldBe` singExpected+    it "should render arrayElemParam correctly" $ do+      renderParam arrayElem `shouldBe` arrayElemExpected+    it "should render flagParam correctly" $ do+      renderParam flag `shouldBe` flagExpected
+ test/Servant/API/Routes/PathSpec.hs view
@@ -0,0 +1,94 @@+module Servant.API.Routes.PathSpec where++import qualified Data.Text as T+import Data.Typeable+import Servant.API.Routes.Internal.Path+import Servant.API.Routes.Path+import Test.Hspec as H+import Test.Hspec.QuickCheck as H+import Test.QuickCheck as Q++genAlphaText :: Q.Gen T.Text+genAlphaText =+  T.pack <$> do+    first' <- alnum+    rest <- Q.listOf alnumOrOther+    last' <- alnum+    pure $ first' : rest <> [last']+  where+    alnumChars = ['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9']+    alnum = Q.elements alnumChars+    alnumOrOther = Q.elements $ alnumChars <> "-_"++shrinkText :: T.Text -> [T.Text]+shrinkText = fmap T.pack . filter (not . null) . Q.shrinkList (const []) . T.unpack++genStringPart :: Q.Gen T.Text+genStringPart = genAlphaText++genTypeRep :: Q.Gen TypeRep+genTypeRep =+  Q.elements+    [ typeRep (Proxy @Int)+    , typeRep (Proxy @String)+    , typeRep (Proxy @())+    , typeRep (Proxy @[Int])+    ]++genPathPart :: Q.Gen PathPart+genPathPart =+  Q.frequency+    [ (6, StringPart <$> genStringPart)+    , (3, CapturePart <$> genStringPart <*> genTypeRep)+    , (1, CaptureAllPart <$> genStringPart <*> genTypeRep)+    ]++genStringParts :: Q.Gen T.Text+genStringParts = unSplit <$> Q.listOf genStringPart++shrinkStringPart :: T.Text -> [T.Text]+shrinkStringPart = shrinkText++shrinkStringParts :: T.Text -> [T.Text]+shrinkStringParts = fmap unSplit . Q.shrinkList shrinkStringPart . T.splitOn "/"++unSplit :: [T.Text] -> T.Text+unSplit = mappend pathSeparator . T.intercalate pathSeparator++shrinkPathPart :: PathPart -> [PathPart]+shrinkPathPart = \case+  StringPart str -> StringPart <$> shrinkStringPart str+  CapturePart name tRep -> [CapturePart name' tRep | name' <- shrinkStringPart name]+  CaptureAllPart name tRep -> [CaptureAllPart name' tRep | name' <- shrinkStringPart name]++normalise :: T.Text -> T.Text+normalise =+  unSplit+    . filter (not . T.null)+    . T.splitOn pathSeparator++instance Q.Arbitrary Path where+  arbitrary = Path <$> Q.listOf genPathPart+  shrink = fmap Path . Q.shrinkList shrinkPathPart . unPath++testPrep :: T.Text -> Path -> Q.Property+testPrep part path =+  let preped = prependPathPart part path+      lhs = renderPath preped+      rhs = part <> pathSeparator <> renderPath path+  in  normalise lhs === normalise rhs++spec :: Spec+spec = do+  describe "renderPath" $ do+    it "should render the root path correctly" $+      renderPath rootPath `shouldBe` pathSeparator+  describe "prependPathPart" $ do+    H.prop "should correctly prepend a single path part" $+      \(path :: Path) ->+        Q.forAllShrink genStringPart shrinkStringPart $ \part ->+          testPrep part path+    H.prop "should correctly prepend a multi-part path" $+      \(path :: Path) ->+        Q.forAllShrink genStringParts shrinkStringParts $ \part ->+          testPrep part path
+ test/Servant/API/Routes/RequestSpec.hs view
@@ -0,0 +1,33 @@+module Servant.API.Routes.RequestSpec+  ( spec+  )+where++import Servant.API.Routes.Internal.Request+import "this" Servant.API.Routes.SomeSpec hiding (spec)+import Servant.API.Routes.Util+import Test.Hspec as H+import Test.Hspec.QuickCheck as H+import Test.QuickCheck as Q++{- hlint ignore "Monoid law, right identity" -}+{- hlint ignore "Monoid law, left identity" -}+{- hlint ignore "Use fold" -}++instance Q.Arbitrary Request where+  arbitrary = Request <$> genSome (Q.elements [intTypeRep, strTypeRep, unitTypeRep])+  shrink = unRequest (shrinkSome (const []))++spec :: Spec+spec = do+  describe "Semigroup/Monoid laws" $ do+    prop "Associativity" $ do+      \(x :: Request, y, z) -> x <> (y <> z) === (x <> y) <> z+    prop "Right identity" $+      \(x :: Request) -> x <> mempty === x+    prop "Left identity" $ do+      \(x :: Request) -> mempty <> x === x+    prop "Concatentation" $ do+      \(xs :: [Request]) -> mconcat xs === foldr (<>) mempty xs+  it "AllTypeable" $ do+    typeReps @'[Int, String] `shouldMatchList` [intTypeRep, strTypeRep]
+ test/Servant/API/Routes/ResponseSpec.hs view
@@ -0,0 +1,42 @@+module Servant.API.Routes.ResponseSpec+  ( spec+  )+where++import qualified Data.Set as Set+import "this" Servant.API.Routes.HeaderSpec (sampleReps)+import Servant.API.Routes.Internal.Response+import "this" Servant.API.Routes.SomeSpec hiding (spec)+import Servant.API.Routes.Util+import Test.Hspec as H+import Test.Hspec.QuickCheck as H+import Test.QuickCheck as Q++{- hlint ignore "Monoid law, right identity" -}+{- hlint ignore "Monoid law, left identity" -}+{- hlint ignore "Use fold" -}++instance Q.Arbitrary Responses where+  arbitrary = Responses <$> genSome arbitrary+  shrink = unResponses (shrinkSome shrink)++instance Q.Arbitrary Response where+  arbitrary = do+    _responseType <- Q.elements [intTypeRep, strTypeRep, unitTypeRep]+    _responseHeaders <- Set.fromList <$> Q.sublistOf sampleReps+    pure Response {..}+  shrink =+    responseHeaders $+      fmap Set.fromList . Q.shrinkList (const []) . Set.toList++spec :: Spec+spec = do+  describe "Semigroup/Monoid laws" $ do+    prop "Associativity" $ do+      \(x :: Responses, y, z) -> x <> (y <> z) === (x <> y) <> z+    prop "Right identity" $+      \(x :: Responses) -> x <> mempty === x+    prop "Left identity" $ do+      \(x :: Responses) -> mempty <> x === x+    prop "Concatentation" $ do+      \(xs :: [Responses]) -> mconcat xs === foldr (<>) mempty xs
+ test/Servant/API/Routes/RouteSpec.hs view
@@ -0,0 +1,76 @@+module Servant.API.Routes.RouteSpec+  ( spec+  )+where++import Data.Function+import qualified Data.Set as Set+import qualified Data.Text as T+import Lens.Micro+import Network.HTTP.Types.Method+import Servant.API.Routes.HeaderSpec hiding (spec)+import Servant.API.Routes.Internal.Auth+import Servant.API.Routes.Internal.Path+import Servant.API.Routes.Internal.Route+import Servant.API.Routes.ParamSpec hiding (spec)+import Servant.API.Routes.PathSpec (genAlphaText, shrinkText)+import Servant.API.Routes.RequestSpec ()+import Servant.API.Routes.ResponseSpec ()+import Servant.API.Routes.Route+import Test.Hspec as H+import Test.QuickCheck as Q++instance Q.Arbitrary Route where+  arbitrary = do+    _routeMethod <- renderStdMethod <$> Q.arbitraryBoundedEnum+    _routePath <- arbitrary+    _routeParams <- Set.fromList <$> Q.sublistOf [sing, arrayElem, flag]+    _routeRequestHeaders <- Set.fromList <$> Q.sublistOf sampleReps+    _routeRequestBody <- arbitrary+    _routeResponse <- arbitrary+    _routeAuths <- Set.fromList <$> Q.listOf genAuths++    pure Route {..}+    where+      genAuths =+        Q.oneof+          [ Basic <$> genAlphaText+          , Custom <$> genAlphaText+          ]++  shrink r =+    routeMethod shrinkMethod r+      <> routePath Q.shrink r+      <> routeParams shrinkSubset r+      <> routeRequestHeaders shrinkSubset r+      <> routeRequestBody Q.shrink r+      <> routeResponse Q.shrink r+      <> routeAuths (shrinkSet shrinkAuth) r+    where+      shrinkMethod = either (const []) (fmap renderStdMethod . Q.shrinkBoundedEnum) . parseMethod+      shrinkSet shr = fmap Set.fromList . Q.shrinkList shr . Set.toList+      shrinkSubset :: Ord a => Set.Set a -> [Set.Set a]+      shrinkSubset = shrinkSet (const [])+      shrinkAuth = \case+        Basic realm -> Basic <$> shrinkText realm+        Custom tag -> Custom <$> shrinkText tag++spec :: Spec+spec = do+  describe "Route" $ do+    describe "renderRoute" $ do+      it "renders default route correctly" $+        renderRoute (defRoute "POST") `shouldBe` "POST " <> pathSeparator+      it "renders path correctly" $+        let route =+              defRoute "GET"+                & routePath .~ Path ["api", "v2"]+            expected = "GET /api/v2"+        in  renderRoute route `shouldBe` expected+      it "renders query params correctly" $+        let route =+              defRoute "PUT"+                & routePath .~ Path ["api", "v2"]+                & routeParams .~ Set.fromList [sing, arrayElem, flag]+            expected = "PUT /api/v2?" <> T.intercalate "&" [singExpected, arrayElemExpected, flagExpected]+        in  renderRoute route `shouldBe` expected
+ test/Servant/API/Routes/SomeSpec.hs view
@@ -0,0 +1,57 @@+module Servant.API.Routes.SomeSpec+  ( spec+  , genSome+  , shrinkSome+  )+where++import Servant.API.Routes.Internal.Some as S+import Test.Hspec as H+import Test.Hspec.QuickCheck as H+import Test.QuickCheck as Q++{- hlint ignore "Monoid law, right identity" -}+{- hlint ignore "Monoid law, left identity" -}+{- hlint ignore "Use fold" -}++genSome :: Q.Gen a -> Q.Gen (Some a)+genSome gen =+  Q.frequency+    [ (1, pure S.None)+    , (2, S.One <$> gen)+    , (4, S.Many . unAtLeast2 <$> genAtLeast2 gen)+    ]++shrinkSome :: (a -> [a]) -> Some a -> [Some a]+shrinkSome shr = fmap S.fromList . Q.shrinkList shr . S.toList++instance Q.Arbitrary a => Q.Arbitrary (Some a) where+  arbitrary = genSome arbitrary+  shrink = shrinkSome shrink++newtype AtLeast2 a = AtLeast2 {unAtLeast2 :: [a]}+  deriving (Show, Eq) via [a]++genAtLeast2 :: Q.Gen a -> Q.Gen (AtLeast2 a)+genAtLeast2 gen = do+  a1 <- gen+  a2 <- gen+  as <- Q.listOf gen+  pure . AtLeast2 $ a1 : a2 : as++instance Arbitrary a => Arbitrary (AtLeast2 a) where+  arbitrary = genAtLeast2 arbitrary+  shrink = fmap AtLeast2 . filter ((>= 2) . length) . shrink . unAtLeast2++spec :: Spec+spec = do+  describe "Some a <-> [a]" $ do+    it "None" $ testList @Int []+    prop "oneType" $ \(x :: Int) -> testList [x]+    prop "manyTypes" $ \(AtLeast2 (xs :: [Int])) -> testList xs+  where+    testList :: forall a. (Eq a, Show a) => [a] -> Q.Property+    testList lst =+      let some = S.fromList lst+          lst' = S.toList some+      in  lst === lst'
+ test/Servant/API/Routes/Util.hs view
@@ -0,0 +1,12 @@+module Servant.API.Routes.Util where++import Data.Typeable++intTypeRep :: TypeRep+intTypeRep = typeRep $ Proxy @Int++strTypeRep :: TypeRep+strTypeRep = typeRep $ Proxy @String++unitTypeRep :: TypeRep+unitTypeRep = typeRep $ Proxy @()
+ test/Servant/API/RoutesSpec.hs view
@@ -0,0 +1,214 @@+{-# LANGUAGE CPP #-}++module Servant.API.RoutesSpec+  ( spec+  )+where++import Data.Function+import qualified Data.Set as Set+import qualified Data.Text as T+import GHC.Generics+import Lens.Micro+import Servant.API+import Servant.API.Routes+import Servant.API.Routes.Internal.Response+import Servant.API.Routes.Route+import Servant.API.Routes.RouteSpec ()+import Test.Hspec as H+import Test.Hspec.QuickCheck as H+import Test.QuickCheck as Q++instance Q.Arbitrary Routes where+  -- we use the 'Routes' pattern to handle removing duplicates etc+  arbitrary = Routes <$> Q.arbitrary+  shrink (Routes routes) = Routes <$> Q.shrink routes++type SubAPI = ReqBody '[JSON] String :> Post '[JSON] Int++type SubAPI2 = Header "h1" T.Text :> "x" :> ("y" :> Put '[JSON] String :<|> SubAPI)++type SubAPI3 =+  Header "h1" T.Text+    :> "x"+    :> ( "y" :> Put '[JSON] String+          :<|> "z" :> Header' '[Optional] "h2" Int :> Get '[JSON] [Integer]+       )++intRequest :: Request+intRequest = oneRequest @Int++intResponse :: Responses+intResponse = oneResponse @Int++strResponse :: Responses+strResponse = oneResponse @String++#if MIN_VERSION_servant(0,19,0)+data API mode = API+  { ep1 :: mode :- SubAPI+  , ep2 :: mode :- SubAPI2+  , ep3 :: mode :- SubAPI3+  } deriving Generic+#endif++sameRoutes ::+  forall l r.+  (HasRoutes l, HasRoutes r) =>+  Expectation+sameRoutes = getRoutes @l `shouldMatchList` getRoutes @r++sameRoutesAsSub ::+  forall l.+  (HasRoutes l) =>+  Expectation+sameRoutesAsSub = sameRoutes @l @SubAPI++unchanged ::+  forall l.+  (HasRoutes (l :> SubAPI)) =>+  Expectation+unchanged = sameRoutesAsSub @(l :> SubAPI)++spec :: Spec+spec = do+  describe "Routes" $ do+    describe "makeRoutes/unmakeRoutes" $+      prop "correctly removes duplicates" $+        \routes ->+          let -- ~ routesList = unmakeRoutes routes+              Routes routesList = routes+              -- ~ routes2 = unmakeRoutes (routesList <> routesList)+              routes2 = Routes (routesList <> routesList)+          in  routes2 === routes+  describe "HasRoutes" $ do+    describe "base cases" $ do+      it "EmptyAPI" $ getRoutes @EmptyAPI `shouldMatchList` []+      it "UVerb" $ do+        getRoutes @(UVerb 'POST '[JSON] '[])+          `shouldMatchList` [ defRoute "POST"+                            ]+        getRoutes @(UVerb 'POST '[JSON] '[Int])+          `shouldMatchList` [ defRoute "POST"+                                & routeResponse .~ intResponse+                            ]+        getRoutes @(UVerb 'POST '[JSON] '[Int, String])+          `shouldMatchList` [ defRoute "POST"+                                & routeResponse .~ intResponse <> strResponse+                            ]+        getRoutes @(UVerb 'POST '[JSON] '[Headers '[] Int, String])+          `shouldMatchList` [ defRoute "POST"+                                & routeResponse .~ strResponse <> intResponse+                            ]+        getRoutes+          @( UVerb+              'POST+              '[JSON]+              '[ Headers '[] (Headers '[Header "h2" String] Int)+               , Headers '[Header "h1" [Int], Header "h3" Int] String+               ]+           )+          `shouldMatchList` [ defRoute "POST"+                                & routeResponse+                                  .~ ( strResponse+                                        & unResponses . traversed . responseHeaders+                                          <>~ Set.fromList+                                            [ mkHeaderRep @"h1" @[Int]+                                            , mkHeaderRep @"h3" @Int+                                            ]+                                     )+                                    <> ( intResponse+                                          & (unResponses . traversed . responseHeaders)+                                            `add` (mkHeaderRep @"h2" @String)+                                       )+                            ]+      it "Verb" $ do+        getRoutes @(Post '[JSON] Int) `shouldMatchList` [defRoute "POST" & routeResponse .~ intResponse]+        getRoutes @(Post '[JSON] (Headers '[Header "h1" String] Int))+          `shouldMatchList` [ defRoute "POST"+                                & routeResponse .~ oneResponse @(Headers '[Header "h1" String] Int)+                            ]+      it "Stream" $ do+        getRoutes @(Stream 'POST 201 NoFraming JSON Int) `shouldMatchList` [defRoute "POST" & routeResponse .~ intResponse]++    describe "boring: combinators that don't change routes" $ do+      it "Description" $ unchanged @(Description "desc")+      it "Summary" $ unchanged @(Summary "summary")+      it "Fragment" $ unchanged @(Fragment Int)+      it "Vault" $ unchanged @Vault+      it "HttpVersion" $ unchanged @HttpVersion+      it "IsSecure" $ unchanged @IsSecure+      it "RemoteHost" $ unchanged @RemoteHost+      it "WithNamedContext" $ sameRoutesAsSub @(WithNamedContext "name" '[] SubAPI)++    describe "recursive: some combinators combine or alter routes" $ do+      it ":<|>" $ getRoutes @(SubAPI :<|> SubAPI2) `shouldMatchList` getRoutes @SubAPI <> getRoutes @SubAPI2+      it "NoContentVerb" $+        renderRoute <$> getRoutes @(NoContentVerb 'POST) `shouldMatchList` ["POST /"]+      it "Symbol :>" $ do+        let prep = routePath %~ prependPathPart "sym"+        getRoutes @("sym" :> SubAPI) `shouldMatchList` prep <$> getRoutes @SubAPI+        getRoutes @("sym" :> SubAPI2) `shouldMatchList` prep <$> getRoutes @SubAPI2+        getRoutes @("sym" :> SubAPI3) `shouldMatchList` prep <$> getRoutes @SubAPI3+      it "Header' :>" $ do+        let addH = routeRequestHeaders `add` (mkHeaderRep @"h1" @Int)+        getRoutes @(Header' '[Required] "h1" Int :> SubAPI) `shouldMatchList` addH <$> getRoutes @SubAPI+        getRoutes @(Header' '[Required] "h1" Int :> SubAPI2) `shouldMatchList` addH <$> getRoutes @SubAPI2+        getRoutes @(Header' '[Required] "h1" Int :> SubAPI3) `shouldMatchList` addH <$> getRoutes @SubAPI3+        let addHOptional = routeRequestHeaders `add` (mkHeaderRep @"h1" @(Maybe Int))+        getRoutes @(Header' '[Optional] "h1" Int :> SubAPI) `shouldMatchList` addHOptional <$> getRoutes @SubAPI+        getRoutes @(Header' '[Optional] "h1" Int :> SubAPI2) `shouldMatchList` addHOptional <$> getRoutes @SubAPI2+        getRoutes @(Header' '[Optional] "h1" Int :> SubAPI3) `shouldMatchList` addHOptional <$> getRoutes @SubAPI3+      it "BasicAuth :>" $ do+        let addAuth = routeAuths `add` basicAuth @"realm"+        getRoutes @(BasicAuth "realm" String :> SubAPI) `shouldMatchList` addAuth <$> getRoutes @SubAPI+        getRoutes @(BasicAuth "realm" String :> SubAPI2) `shouldMatchList` addAuth <$> getRoutes @SubAPI2+        getRoutes @(BasicAuth "realm" String :> SubAPI3) `shouldMatchList` addAuth <$> getRoutes @SubAPI3+      it "AuthProtect :>" $ do+        let addAuth = routeAuths `add` customAuth @"my-special-auth"+        getRoutes @(AuthProtect "my-special-auth" :> SubAPI) `shouldMatchList` addAuth <$> getRoutes @SubAPI+        getRoutes @(AuthProtect "my-special-auth" :> SubAPI2) `shouldMatchList` addAuth <$> getRoutes @SubAPI2+        getRoutes @(AuthProtect "my-special-auth" :> SubAPI3) `shouldMatchList` addAuth <$> getRoutes @SubAPI3+      it "QueryFlag :>" $ do+        let addFlag = routeParams `add` (flagParam @"sym")+        getRoutes @(QueryFlag "sym" :> SubAPI) `shouldMatchList` addFlag <$> getRoutes @SubAPI+        getRoutes @(QueryFlag "sym" :> SubAPI2) `shouldMatchList` addFlag <$> getRoutes @SubAPI2+        getRoutes @(QueryFlag "sym" :> SubAPI3) `shouldMatchList` addFlag <$> getRoutes @SubAPI3+      it "QueryParam' :>" $ do+        let addP = routeParams `add` (singleParam @"h1" @Int)+        getRoutes @(QueryParam' '[Required] "h1" Int :> SubAPI) `shouldMatchList` addP <$> getRoutes @SubAPI+        getRoutes @(QueryParam' '[Required] "h1" Int :> SubAPI2) `shouldMatchList` addP <$> getRoutes @SubAPI2+        getRoutes @(QueryParam' '[Required] "h1" Int :> SubAPI3) `shouldMatchList` addP <$> getRoutes @SubAPI3+        let addPOptional = routeParams `add` (singleParam @"h1" @(Maybe Int))+        getRoutes @(QueryParam' '[Optional] "h1" Int :> SubAPI) `shouldMatchList` addPOptional <$> getRoutes @SubAPI+        getRoutes @(QueryParam' '[Optional] "h1" Int :> SubAPI2) `shouldMatchList` addPOptional <$> getRoutes @SubAPI2+        getRoutes @(QueryParam' '[Optional] "h1" Int :> SubAPI3) `shouldMatchList` addPOptional <$> getRoutes @SubAPI3+      it "QueryParams :>" $ do+        let addP = routeParams `add` (arrayElemParam @"h1" @Int)+        getRoutes @(QueryParams "h1" Int :> SubAPI) `shouldMatchList` addP <$> getRoutes @SubAPI+        getRoutes @(QueryParams "h1" Int :> SubAPI2) `shouldMatchList` addP <$> getRoutes @SubAPI2+        getRoutes @(QueryParams "h1" Int :> SubAPI3) `shouldMatchList` addP <$> getRoutes @SubAPI3+      it "ReqBody' :>" $ do+        let addB = routeRequestBody <>~ intRequest+        getRoutes @(ReqBody '[JSON] Int :> SubAPI) `shouldMatchList` addB <$> getRoutes @SubAPI+        getRoutes @(ReqBody '[JSON] Int :> SubAPI2) `shouldMatchList` addB <$> getRoutes @SubAPI2+        getRoutes @(ReqBody '[JSON] Int :> SubAPI3) `shouldMatchList` addB <$> getRoutes @SubAPI3+      it "StreamBody' :>" $ do+        let addB = routeRequestBody <>~ intRequest+        getRoutes @(ReqBody '[JSON] Int :> SubAPI) `shouldMatchList` addB <$> getRoutes @SubAPI+        getRoutes @(ReqBody '[JSON] Int :> SubAPI2) `shouldMatchList` addB <$> getRoutes @SubAPI2+        getRoutes @(StreamBody NoFraming JSON Int :> SubAPI3) `shouldMatchList` addB <$> getRoutes @SubAPI3+      it "Capture' :>" $ do+        let addC = routePath %~ prependCapturePart @Int "cap"+        getRoutes @(Capture "cap" Int :> SubAPI) `shouldMatchList` addC <$> getRoutes @SubAPI+        getRoutes @(Capture "cap" Int :> SubAPI2) `shouldMatchList` addC <$> getRoutes @SubAPI2+        getRoutes @(Capture "cap" Int :> SubAPI3) `shouldMatchList` addC <$> getRoutes @SubAPI3+      it "CaptureAll :>" $ do+        let addC = routePath %~ prependCaptureAllPart @Int "cap"+        getRoutes @(CaptureAll "cap" Int :> SubAPI) `shouldMatchList` addC <$> getRoutes @SubAPI+        getRoutes @(CaptureAll "cap" Int :> SubAPI2) `shouldMatchList` addC <$> getRoutes @SubAPI2+        getRoutes @(CaptureAll "cap" Int :> SubAPI3) `shouldMatchList` addC <$> getRoutes @SubAPI3+#if MIN_VERSION_servant(0,19,0)+      it "NamedRoutes" $+        getRoutes @(NamedRoutes API) `shouldMatchList` getRoutes @SubAPI <> getRoutes @SubAPI2 <> getRoutes @SubAPI3+#endif
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}