diff --git a/solga-core/LICENSE b/solga-core/LICENSE new file mode 100644 index 0000000..a31a05d --- /dev/null +++ b/solga-core/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 Patrick Chilton + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/solga-core/Setup.hs b/solga-core/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/solga-core/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/solga-core/solga-core.cabal b/solga-core/solga-core.cabal new file mode 100644 index 0000000..054cf86 --- /dev/null +++ b/solga-core/solga-core.cabal @@ -0,0 +1,25 @@ +name: solga-core +version: 0.1.0.0 +synopsis: Simple typesafe web routing +description: A library for easily specifying web APIs and implementing them in a type-safe way. +license: MIT +license-file: LICENSE +author: Patrick Chilton +maintainer: chpatrick@gmail.com +copyright: Copyright (C) 2016 Patrick Chilton +category: Web +build-type: Simple +homepage: https://github.com/chpatrick/solga +bug-reports: https://github.com/chpatrick/solga/issues +-- extra-source-files: +cabal-version: >=1.10 + +library + exposed-modules: Solga.Core + build-depends: base >= 4.8 && < 5, + case-insensitive, + bytestring + hs-source-dirs: src + default-language: Haskell2010 + ghc-options: -Wall + diff --git a/solga-core/src/Solga/Core.hs b/solga-core/src/Solga/Core.hs new file mode 100644 index 0000000..c154b6e --- /dev/null +++ b/solga-core/src/Solga/Core.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE DefaultSignatures #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE NamedFieldPuns #-} +module Solga.Core + ( -- * Path components + type (:>), type (/>) + , Get + , Post + , JSON(..) + , Raw(..) + , RawResponse(..) + , End(..) + , WithIO(..) + , Seg(..) + , OneOfSegs(..) + , Capture(..) + , Method(..) + , HeaderName + , Header + , ResponseHeaders + , ExtraHeaders(..) + , NoCache(..) + , ReqBodyJSON(..) + , MultiPartParam + , MultiPartFile + , MultiPartFileInfo(..) + , MultiPartData + , ReqBodyMultipart(..) + , Endpoint + , (:<|>)(..) + ) where + +import GHC.TypeLits +import Data.ByteString (ByteString) +import Data.CaseInsensitive (CI) + +--------------------------------------------------- + +-- | Compose routers. This is just type application, +-- ie.: @Foo :> Bar :> Baz == Foo (Bar Baz)@ +type f :> g = f g +infixr 2 :> + +-- | Serve a given WAI `Wai.Application`. +newtype Raw a = Raw { rawApp :: a } + +-- | Serve a given WAI `Wai.Response`. +newtype RawResponse a = RawResponse { rawResponse :: a } + +-- | Only accept the end of a path. +newtype End next = End { endNext :: next } + +-- | Match a constant directory in the path. +-- +-- When specifying APIs, use the `/>` combinator to specify sub-paths: +-- @"foo" `/>` `JSON` Bar@ +newtype Seg (seg :: Symbol) next = Seg { segNext :: next } + deriving (Eq, Ord, Show) + +-- | Match a path, segment, e.g @"foo" `/>` `JSON` Bar@ +type seg /> g = Seg seg :> g +infixr 2 /> + +-- | Try to route with @left@, or try to route with @right@. +data left :<|> right = (:<|>) { altLeft :: left, altRight :: right } + deriving (Eq, Ord, Show) + +infixr 1 :<|> + +-- | Match any of a set of path segments. +data OneOfSegs (segs :: [ Symbol ]) next = OneOfSegs { oneOfSegsNext :: next } + +-- | Capture a path segment and pass it on. +newtype Capture a next = Capture { captureNext :: a -> next } + +-- | Accepts requests with a certain method. +newtype Method (method :: Symbol) next = Method { methodNext :: next } + deriving (Eq, Ord, Show) + +-- | Return a given JSON object +newtype JSON a = JSON { jsonResponse :: a } + deriving (Eq, Ord, Show) + +type HeaderName = CI ByteString +type Header = (HeaderName, ByteString) +type ResponseHeaders = [Header] + +-- | Set extra headers on responses. +-- Existing headers will be overriden if specified here. +data ExtraHeaders next = ExtraHeaders + { extraHeaders :: ResponseHeaders + , extraHeadersNext :: next + } + +-- | Prevent caching for sub-routers. +newtype NoCache next = NoCache { noCacheNext :: next } + +-- | Parse a JSON request body. +newtype ReqBodyJSON a next = ReqBodyJSON { reqBodyJSONNext :: a -> next } + +-- | Produce a response with `IO`. +newtype WithIO next = WithIO { withIONext :: IO next } + +type MultiPartParam = (ByteString, ByteString) +type MultiPartFile y = (ByteString, MultiPartFileInfo y) + +data MultiPartFileInfo c = MultiPartFileInfo + { mpfiName :: ByteString + , mpfiContentType :: ByteString + , mpfiContent :: FilePath + } + +-- | A parsed "multipart/form-data" request. +type MultiPartData y = ([MultiPartParam], [MultiPartFile y]) + +-- | Accept a "multipart/form-data" request. +-- Files will be stored in a temporary directory and will be deleted +-- automatically after the request is processed. +data ReqBodyMultipart y a next = ReqBodyMultipart + { reqMultiPartParse :: MultiPartData y -> Either String a + , reqMultiPartNext :: a -> next + } + +-- | Useful synonym for dynamic endpoints: accept requests with a given method, compute a JSON response in `IO` and don't cache. +type Endpoint method a = End :> NoCache :> Method method :> WithIO :> a + +-- | Handle a "GET" request and produce a "JSON" response, with `IO`. +type Get a = Endpoint "GET" (JSON a) +-- | Handle a "POST" request and produce a "JSON" response, with `IO`. +type Post a = Endpoint "POST" (JSON a) + diff --git a/solga-router/LICENSE b/solga-router/LICENSE new file mode 100644 index 0000000..a31a05d --- /dev/null +++ b/solga-router/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 Patrick Chilton + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/solga-router/Setup.hs b/solga-router/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/solga-router/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/solga-router/solga-router.cabal b/solga-router/solga-router.cabal new file mode 100644 index 0000000..79cfcef --- /dev/null +++ b/solga-router/solga-router.cabal @@ -0,0 +1,56 @@ +name: solga-router +version: 0.1.0.0 +synopsis: Simple typesafe web routing +description: A library for easily specifying web APIs and implementing them in a type-safe way. +license: MIT +license-file: LICENSE +author: Patrick Chilton +maintainer: chpatrick@gmail.com +copyright: Copyright (C) 2016 Patrick Chilton +category: Web +build-type: Simple +homepage: https://github.com/chpatrick/solga +bug-reports: https://github.com/chpatrick/solga/issues +-- extra-source-files: +cabal-version: >=1.10 + +library + exposed-modules: Solga.Router + build-depends: base >= 4.8 && < 5, + solga-core, + text, + wai, + bytestring, + containers, + aeson >= 1.0.0.0, + wai-extra, + http-types, + resourcet, + safe-exceptions + hs-source-dirs: src + default-language: Haskell2010 + ghc-options: -Wall + +test-suite solga-router-tests + type: exitcode-stdio-1.0 + hs-source-dirs: test + main-is: Test.hs + ghc-options: -Wall + default-language: Haskell2010 + build-depends: base + , solga-router + , solga-core + , text + , bytestring + , wai + , wai-extra + , aeson + , hspec + , hspec-wai + , hspec-wai-json + , http-types + , unordered-containers + , hashable + , vector + , scientific + , QuickCheck diff --git a/solga-router/src/Solga/Router.hs b/solga-router/src/Solga/Router.hs new file mode 100644 index 0000000..7844574 --- /dev/null +++ b/solga-router/src/Solga/Router.hs @@ -0,0 +1,289 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE DefaultSignatures #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RecordWildCards #-} +module Solga.Router + ( -- * Serving APIs + serve, serveThrow + -- * Abbreviation + , Abbreviated(..) + -- * Error handling + , SolgaError + , badRequest + , notFound + -- * Router implementation + , FromSegment(..) + , Router(..) + , Responder + , tryRouteNext + , tryRouteNextIO + ) where + +import Control.Applicative +import Control.Exception.Safe +import Control.Monad +import Control.Monad.Trans.Resource +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Builder as Builder +import qualified Data.ByteString.Char8 as Char8 +import qualified Data.Map.Strict as Map +import Data.Monoid +import Data.Proxy +import qualified Data.Text as Text +import Data.Text.Encoding +import GHC.Generics +import GHC.TypeLits +import qualified Network.Wai as Wai +import qualified Network.Wai.Parse as Wai +import qualified Network.HTTP.Types as HTTP + +import Solga.Core + +--------------------------------------------------- + +-- | The right hand side of `Application`. `Request` is already known. +type Responder = (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived + +-- | Routers are the basic typeclass of Solga: their types describe +-- what type of requests they accept, and their values describe how to handle them. +-- +-- You can use `Generic` to get free instance of `Router` for any data type with one constructor +-- and `Router`s as fields. The fields will be considered alternatives, as if you wrote `:<|>` between them. +class Router r where + -- | Given a request, if the router supports the given request + -- return a function that constructs a response with a concrete router. + tryRoute :: Wai.Request -> Maybe (r -> Responder) + default tryRoute :: (Generic r, Router (Rep r ())) => Wai.Request -> Maybe (r -> Responder) + tryRoute = tryRouteNext (from :: r -> Rep r ()) + +-- | Try to route using a type @r@ by providing a function to turn it into a `Router` @r'@. +-- Useful for passing routing on to the next step. +tryRouteNext :: Router r' => (r -> r') -> Wai.Request -> Maybe (r -> Responder) +tryRouteNext f req = (. f) <$> tryRoute req + +-- | Like `tryRouteNext` but in `IO`. +tryRouteNextIO :: Router r' => (r -> IO r') -> Wai.Request -> Maybe (r -> Responder) +tryRouteNextIO f req = do + nextRouter <- tryRoute req + Just $ \router cont -> do + next <- f router + nextRouter next cont + +-- | Serve a `Router` with Solga, returning `SolgaError`s as HTTP responses and other errors as HTTP 500. +serve :: Router r => r -> Wai.Application +serve router req cont = + serveThrow router req cont + `catchAny` \someEx -> + let + ( status, body ) = case fromException someEx of + Just SolgaError { errorStatus, errorMessage } -> ( errorStatus, Builder.byteString $ encodeUtf8 errorMessage ) + Nothing -> ( HTTP.internalServerError500, "Internal Server Error" ) + in cont $ Wai.responseBuilder status [] body + +-- | Serve a `Router` with Solga, throwing `SolgaError`s. +serveThrow :: Router r => r -> Wai.Application +serveThrow router req cont = case tryRoute req of + Nothing -> throwIO $ notFound "" + Just r -> r router cont + +instance (a ~ Wai.Application) => Router (Raw a) where + tryRoute req = Just $ \(Raw app) -> app req + +instance (a ~ Wai.Response) => Router (RawResponse a) where + tryRoute _ = Just $ \(RawResponse response) cont -> cont response + +instance Router next => Router (End next) where + tryRoute req = case Wai.pathInfo req of + [] -> tryRouteNext endNext req + _ -> Nothing + +instance (KnownSymbol seg, Router next) => Router (Seg seg next) where + tryRoute req = case Wai.pathInfo req of + s : segs | Text.unpack s == symbolVal (Proxy :: Proxy seg) -> + tryRouteNext segNext req { Wai.pathInfo = segs } + _ -> Nothing + +instance (Router left, Router right) => Router (left :<|> right) where + tryRoute req = tryRouteNext altLeft req <|> tryRouteNext altRight req + +instance (KnownSymbol seg, Router next, Router (OneOfSegs segs next)) => Router (OneOfSegs (seg ': segs) next) where + tryRoute = tryRouteNext $ \(OneOfSegs next) -> (Seg next :: Seg seg next) :<|> (OneOfSegs next :: OneOfSegs segs next) + +instance Router next => Router (OneOfSegs '[] next) where + tryRoute _ = Nothing + +-- | The class of types that can be parsed from a path segment. +class FromSegment a where + fromSegment :: Text.Text -> Maybe a + +instance FromSegment Text.Text where + fromSegment = Just + +instance (FromSegment a, Router next) => Router (Capture a next) where + tryRoute req = case Wai.pathInfo req of + seg : segs -> do + capture <- fromSegment seg + tryRouteNext (\c -> captureNext c capture) req { Wai.pathInfo = segs } + _ -> Nothing + +instance (KnownSymbol method, Router next) => Router (Method method next) where + tryRoute req = do + guard (Char8.unpack (Wai.requestMethod req) == symbolVal (Proxy :: Proxy method)) + tryRouteNext methodNext req + +instance Aeson.ToJSON a => Router (JSON a) where + tryRoute _ = Just $ \json cont -> + cont $ Wai.responseBuilder HTTP.status200 headers $ Aeson.fromEncoding $ Aeson.toEncoding $ jsonResponse json + where headers = [ ( HTTP.hContentType, "application/json" ) ] + +instance Router next => Router (ExtraHeaders next) where + tryRoute req = do + nextRouter <- tryRoute req + return $ \(ExtraHeaders headers next) cont -> do + let addHeaders oldHeaders = Map.assocs (Map.fromList headers `Map.union` Map.fromList oldHeaders) + nextRouter next $ \response -> + cont $ Wai.mapResponseHeaders addHeaders response + +instance Router next => Router (NoCache next) where + tryRoute = tryRouteNext (ExtraHeaders [cacheControlDisableCaching] . noCacheNext) + where + cacheControlDisableCaching = ("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + +instance (Aeson.FromJSON a, Router next) => Router (ReqBodyJSON a next) where + tryRoute req = tryRouteNextIO getNext req + where + getNext rbj = do + reqBody <- Wai.requestBody req + case Aeson.eitherDecodeStrict reqBody of + Left err -> throwIO $ badRequest $ "Could not decode JSON request: " <> Text.pack (show err) + Right val -> return (reqBodyJSONNext rbj val) + +instance Router next => Router (WithIO next) where + tryRoute = tryRouteNextIO withIONext + +instance (fp ~ FilePath, Router next) => Router (ReqBodyMultipart fp a next) where + tryRoute req = flip fmap (tryRoute req) $ \nextRouter rmp cont -> + runResourceT $ withInternalState $ \s -> do + (params, fileInfos0) <- Wai.parseRequestBody (Wai.tempFileBackEnd s) req + let fileInfos = do + (parName, Wai.FileInfo{..}) <- fileInfos0 + return + ( parName + , MultiPartFileInfo + { mpfiName = fileName + , mpfiContentType = fileContentType + , mpfiContent = fileContent + } + ) + let multiPart :: MultiPartData FilePath = (params, fileInfos) + case reqMultiPartParse rmp multiPart of + Left err -> throwIO $ badRequest $ "Could not decode form request: " <> Text.pack err + Right val -> nextRouter (reqMultiPartNext rmp val) cont + +-- | Most `Router`s are really just newtypes. By using `brief`, you can +-- construct trees of `Router`s by providing only their inner types, much +-- like Servant. +class Abbreviated a where + type Brief a :: * + type instance Brief a = a + brief :: Brief a -> a + default brief :: Brief a ~ a => Brief a -> a + brief = id + +instance Abbreviated (Raw a) where + type Brief (Raw a) = a + brief = Raw + +instance Abbreviated (RawResponse a) where + type Brief (RawResponse a) = a + brief = RawResponse + +instance Abbreviated next => Abbreviated (End next) where + type Brief (End next) = Brief next + brief = End . brief + +instance Abbreviated next => Abbreviated (Seg seg next) where + type Brief (Seg seg next) = Brief next + brief = Seg . brief + +instance (Abbreviated left, Abbreviated right) => Abbreviated (left :<|> right) where + type Brief (left :<|> right) = Brief left :<|> Brief right + brief (l :<|> r) = brief l :<|> brief r + +instance Abbreviated next => Abbreviated (OneOfSegs segs next) where + type Brief (OneOfSegs segs next) = Brief next + brief = OneOfSegs . brief + +instance Abbreviated next => Abbreviated (Capture a next) where + type Brief (Capture a next) = a -> Brief next + brief = Capture . fmap brief + +instance Abbreviated next => Abbreviated (Method method next) where + type Brief (Method method next) = Brief next + brief = Method . brief + +instance Abbreviated (JSON a) where + type Brief (JSON a) = a + brief = JSON + +instance Abbreviated (ExtraHeaders next) + +instance Abbreviated next => Abbreviated (NoCache next) where + type Brief (NoCache next) = Brief next + brief = NoCache . brief + +instance Abbreviated next => Abbreviated (ReqBodyJSON a next) where + type Brief (ReqBodyJSON a next) = a -> Brief next + brief = ReqBodyJSON . fmap brief + +instance Abbreviated next => Abbreviated (WithIO next) where + type Brief (WithIO next) = IO (Brief next) + brief = WithIO . fmap brief + +instance Abbreviated (ReqBodyMultipart fp a next) + +-- Generic routers + +deriving instance Router r => Router (K1 i r p) +deriving instance Router (f p) => Router (M1 i c f p) + +instance (Router (left p), Router (right p)) => Router ((left :*: right) p) where + tryRoute req = routeLeft <|> routeRight + where + routeLeft = tryRouteNext (\(left :*: _) -> left) req + routeRight = tryRouteNext (\(_ :*: right) -> right) req + +-- Error handling + +-- | A `Router`-related exception with a corresponding HTTP error code. +data SolgaError = SolgaError + { errorStatus :: HTTP.Status + , errorMessage :: Text.Text + } deriving (Eq, Ord, Show) + +instance Exception SolgaError + +-- | Create a @400 Bad Request@ error with a given message. +badRequest :: Text.Text -> SolgaError +badRequest msg = SolgaError + { errorStatus = HTTP.badRequest400 + , errorMessage = msg + } + +-- | Create a @404 Not Found@ error with a given message. +notFound :: Text.Text -> SolgaError +notFound msg = SolgaError + { errorStatus = HTTP.notFound404 + , errorMessage = msg + } diff --git a/solga/test/Test.hs b/solga-router/test/Test.hs similarity index 98% rename from solga/test/Test.hs rename to solga-router/test/Test.hs index 4ed30aa..83d8828 100644 --- a/solga/test/Test.hs +++ b/solga-router/test/Test.hs @@ -27,7 +27,8 @@ import GHC.Generics (Generic) import Network.HTTP.Types.URI import Network.Wai.Test -import Solga +import Solga.Core +import Solga.Router main :: IO () main = hspec spec @@ -93,7 +94,6 @@ spec = with (return $ serve testAPI) $ do resp <- get path liftIO $ decode (simpleBody resp) `shouldBe` Just (String seg) -deriving instance Generic Value instance Arbitrary Value where arbitrary = sized arbJSON @@ -133,4 +133,4 @@ instance Arbitrary a => Arbitrary (V.Vector a) where instance Arbitrary S.Scientific where arbitrary = S.scientific <$> arbitrary <*> arbitrary - shrink s = map (uncurry S.scientific) $ shrink $ ( S.coefficient s, S.base10Exponent s ) \ No newline at end of file + shrink s = map (uncurry S.scientific) $ shrink $ ( S.coefficient s, S.base10Exponent s ) diff --git a/solga-swagger/solga-swagger.cabal b/solga-swagger/solga-swagger.cabal index b2db570..45cd40d 100644 --- a/solga-swagger/solga-swagger.cabal +++ b/solga-swagger/solga-swagger.cabal @@ -17,7 +17,7 @@ cabal-version: >=1.10 library exposed-modules: Solga.Swagger build-depends: base >= 4.8 && < 5, - solga, + solga-core, swagger2 >= 2.1, lens, text, diff --git a/solga-swagger/src/Solga/Swagger.hs b/solga-swagger/src/Solga/Swagger.hs index ee084de..015d385 100644 --- a/solga-swagger/src/Solga/Swagger.hs +++ b/solga-swagger/src/Solga/Swagger.hs @@ -41,7 +41,7 @@ import GHC.TypeLits import Data.Swagger as Swagger import Data.Swagger.Declare -import Solga +import Solga.Core data Context = Context { contextMethod :: Maybe HTTP.Method -- ^ Any method currently set. @@ -106,7 +106,7 @@ pathsFromContext response ctx@Context { contextMethod, pathSegments, operationCo let pathItem = mempty & methodSetter ?~ operation return $ OHMS.singleton path pathItem -instance RouterSwagger RawResponse where +instance RouterSwagger (RawResponse a) where genPaths _ = pathsFromContext mempty instance ToSchema a => RouterSwagger (JSON a) where @@ -136,7 +136,7 @@ instance RouterSwagger next => RouterSwagger (NoCache next) where instance RouterSwagger next => RouterSwagger (ExtraHeaders next) where genPaths = passPaths -instance RouterSwagger (ReqBodyMultipart a next) where +instance RouterSwagger (ReqBodyMultipart fp a next) where genPaths = noPaths instance RouterSwagger (OneOfSegs '[] next) where @@ -149,7 +149,7 @@ instance (KnownSymbol seg, RouterSwagger next, RouterSwagger (OneOfSegs segs nex nextSegPaths <- genPaths (Proxy :: Proxy (OneOfSegs segs next)) ctx return (nextPaths `OHMS.union` nextSegPaths) -instance RouterSwagger Raw where +instance RouterSwagger (Raw a) where genPaths = noPaths instance (RouterSwagger left, RouterSwagger right) => RouterSwagger (left :<|> right) where diff --git a/solga/solga.cabal b/solga/solga.cabal index 50bcf49..2e176db 100644 --- a/solga/solga.cabal +++ b/solga/solga.cabal @@ -1,5 +1,5 @@ name: solga -version: 0.1.0.2 +version: 0.1.0.3 synopsis: Simple typesafe web routing description: A library for easily specifying web APIs and implementing them in a type-safe way. license: MIT @@ -17,38 +17,8 @@ cabal-version: >=1.10 library exposed-modules: Solga build-depends: base >= 4.8 && < 5, - text, - wai, - bytestring, - containers, - aeson >= 1.0.0.0, - wai-extra, - http-types, - resourcet, - safe-exceptions + solga-core, + solga-router hs-source-dirs: src default-language: Haskell2010 ghc-options: -Wall - -test-suite solga-tests - type: exitcode-stdio-1.0 - hs-source-dirs: test - main-is: Test.hs - ghc-options: -Wall - default-language: Haskell2010 - build-depends: base - , solga - , text - , bytestring - , wai - , wai-extra - , aeson - , hspec - , hspec-wai - , hspec-wai-json - , http-types - , unordered-containers - , hashable - , vector - , scientific - , QuickCheck \ No newline at end of file diff --git a/solga/src/Solga.hs b/solga/src/Solga.hs index 43f212a..4a98447 100644 --- a/solga/src/Solga.hs +++ b/solga/src/Solga.hs @@ -1,374 +1,7 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE FlexibleInstances #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE KindSignatures #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeOperators #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE DefaultSignatures #-} -{-# LANGUAGE StandaloneDeriving #-} -{-# LANGUAGE NamedFieldPuns #-} module Solga - ( -- * Serving APIs - serve, serveThrow - -- * Basic routers - , type (:>), type (/>) - , Get - , Post - , JSON(..) - , Raw(..) - , RawResponse(..) - , End(..) - , WithIO(..) - , Seg(..) - , OneOfSegs(..) - , FromSegment(..) - , Capture(..) - , Method(..) - , ExtraHeaders(..) - , NoCache(..) - , ReqBodyJSON(..) - , MultiPartData - , ReqBodyMultipart(..) - , Endpoint - , (:<|>)(..) - -- * Abbreviation - , Abbreviated(..) - -- * Error handling - , SolgaError - , badRequest - , notFound - -- * Router implementation - , Router(..) - , Responder - , tryRouteNext - , tryRouteNextIO + ( module Solga.Core + , module Solga.Router ) where -import Control.Applicative -import Control.Exception.Safe -import Control.Monad -import Control.Monad.Trans.Resource -import qualified Data.Aeson as Aeson -import qualified Data.Aeson.Encode as Aeson -import qualified Data.ByteString.Builder as Builder -import qualified Data.ByteString.Char8 as Char8 -import qualified Data.Map.Strict as Map -import Data.Monoid -import Data.Proxy -import qualified Data.Text as Text -import Data.Text.Encoding -import GHC.Generics -import GHC.TypeLits -import qualified Network.Wai as Wai -import qualified Network.Wai.Parse as Wai -import qualified Network.HTTP.Types as HTTP - ---------------------------------------------------- - --- | The right hand side of `Application`. `Request` is already known. -type Responder = (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived - --- | Routers are the basic typeclass of Solga: their types describe --- what type of requests they accept, and their values describe how to handle them. --- --- You can use `Generic` to get free instance of `Router` for any data type with one constructor --- and `Router`s as fields. The fields will be considered alternatives, as if you wrote `:<|>` between them. -class Router r where - -- | Given a request, if the router supports the given request - -- return a function that constructs a response with a concrete router. - tryRoute :: Wai.Request -> Maybe (r -> Responder) - default tryRoute :: (Generic r, Router (Rep r ())) => Wai.Request -> Maybe (r -> Responder) - tryRoute = tryRouteNext (from :: r -> Rep r ()) - --- | Try to route using a type @r@ by providing a function to turn it into a `Router` @r'@. --- Useful for passing routing on to the next step. -tryRouteNext :: Router r' => (r -> r') -> Wai.Request -> Maybe (r -> Responder) -tryRouteNext f req = (. f) <$> tryRoute req - --- | Like `tryRouteNext` but in `IO`. -tryRouteNextIO :: Router r' => (r -> IO r') -> Wai.Request -> Maybe (r -> Responder) -tryRouteNextIO f req = do - nextRouter <- tryRoute req - Just $ \router cont -> do - next <- f router - nextRouter next cont - --- | Serve a `Router` with Solga, returning `SolgaError`s as HTTP responses and other errors as HTTP 500. -serve :: Router r => r -> Wai.Application -serve router req cont = - serveThrow router req cont - `catchAny` \someEx -> - let - ( status, body ) = case fromException someEx of - Just SolgaError { errorStatus, errorMessage } -> ( errorStatus, Builder.byteString $ encodeUtf8 errorMessage ) - Nothing -> ( HTTP.internalServerError500, "Internal Server Error" ) - in cont $ Wai.responseBuilder status [] body - --- | Serve a `Router` with Solga, throwing `SolgaError`s. -serveThrow :: Router r => r -> Wai.Application -serveThrow router req cont = case tryRoute req of - Nothing -> throwIO $ notFound "" - Just r -> r router cont - --- | Compose routers. This is just type application, --- ie.: @Foo :> Bar :> Baz == Foo (Bar Baz)@ -type f :> g = f g -infixr 2 :> - --- | Serve a given WAI `Wai.Application`. -newtype Raw = Raw { rawApp :: Wai.Application } - -instance Router Raw where - tryRoute req = Just $ \(Raw app) -> app req - --- | Serve a given WAI `Wai.Response`. -newtype RawResponse = RawResponse { rawResponse :: Wai.Response } -instance Router RawResponse where - tryRoute _ = Just $ \(RawResponse response) cont -> cont response - --- | Only accept the end of a path. -newtype End next = End { endNext :: next } -instance Router next => Router (End next) where - tryRoute req = case Wai.pathInfo req of - [] -> tryRouteNext endNext req - _ -> Nothing - --- | Match a constant directory in the path. --- --- When specifying APIs, use the `/>` combinator to specify sub-paths: --- @"foo" `/>` `JSON` Bar@ -newtype Seg (seg :: Symbol) next = Seg { segNext :: next } - deriving (Eq, Ord, Show) - --- | Match a path, segment, e.g @"foo" `/>` `JSON` Bar@ -type seg /> g = Seg seg :> g -infixr 2 /> - -instance (KnownSymbol seg, Router next) => Router (Seg seg next) where - tryRoute req = case Wai.pathInfo req of - s : segs | Text.unpack s == symbolVal (Proxy :: Proxy seg) -> - tryRouteNext segNext req { Wai.pathInfo = segs } - _ -> Nothing - --- | Try to route with @left@, or try to route with @right@. -data left :<|> right = (:<|>) { altLeft :: left, altRight :: right } - deriving (Eq, Ord, Show) - -infixr 1 :<|> - -instance (Router left, Router right) => Router (left :<|> right) where - tryRoute req = tryRouteNext altLeft req <|> tryRouteNext altRight req - --- | Match any of a set of path segments. -data OneOfSegs (segs :: [ Symbol ]) next = OneOfSegs { oneOfSegsNext :: next } - -instance (KnownSymbol seg, Router next, Router (OneOfSegs segs next)) => Router (OneOfSegs (seg ': segs) next) where - tryRoute = tryRouteNext $ \(OneOfSegs next) -> (Seg next :: Seg seg next) :<|> (OneOfSegs next :: OneOfSegs segs next) - -instance Router next => Router (OneOfSegs '[] next) where - tryRoute _ = Nothing - --- | The class of types that can be parsed from a path segment. -class FromSegment a where - fromSegment :: Text.Text -> Maybe a - -instance FromSegment Text.Text where - fromSegment = Just - --- | Capture a path segment and pass it on. -newtype Capture a next = Capture { captureNext :: a -> next } - -instance (FromSegment a, Router next) => Router (Capture a next) where - tryRoute req = case Wai.pathInfo req of - seg : segs -> do - capture <- fromSegment seg - tryRouteNext (\c -> captureNext c capture) req { Wai.pathInfo = segs } - _ -> Nothing - --- | Accepts requests with a certain method. -newtype Method (method :: Symbol) next = Method { methodNext :: next } - deriving (Eq, Ord, Show) - -instance (KnownSymbol method, Router next) => Router (Method method next) where - tryRoute req = do - guard (Char8.unpack (Wai.requestMethod req) == symbolVal (Proxy :: Proxy method)) - tryRouteNext methodNext req - --- | Return a given JSON object -newtype JSON a = JSON { jsonResponse :: a } - deriving (Eq, Ord, Show) - -instance Aeson.ToJSON a => Router (JSON a) where - tryRoute _ = Just $ \json cont -> - cont $ Wai.responseBuilder HTTP.status200 headers $ Aeson.fromEncoding $ Aeson.toEncoding $ jsonResponse json - where headers = [ ( HTTP.hContentType, "application/json" ) ] - --- | Set extra headers on responses. --- Existing headers will be overriden if specified here. -data ExtraHeaders next = ExtraHeaders - { extraHeaders :: HTTP.ResponseHeaders - , extraHeadersNext :: next - } - -instance Router next => Router (ExtraHeaders next) where - tryRoute req = do - nextRouter <- tryRoute req - return $ \(ExtraHeaders headers next) cont -> do - let addHeaders oldHeaders = Map.assocs (Map.fromList headers `Map.union` Map.fromList oldHeaders) - nextRouter next $ \response -> - cont $ Wai.mapResponseHeaders addHeaders response - --- | Prevent caching for sub-routers. -newtype NoCache next = NoCache { noCacheNext :: next } - -instance Router next => Router (NoCache next) where - tryRoute = tryRouteNext (ExtraHeaders [cacheControlDisableCaching] . noCacheNext) - where - cacheControlDisableCaching = ("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - --- | Parse a JSON request body. -newtype ReqBodyJSON a next = ReqBodyJSON { reqBodyJSONNext :: a -> next } - -instance (Aeson.FromJSON a, Router next) => Router (ReqBodyJSON a next) where - tryRoute req = tryRouteNextIO getNext req - where - getNext rbj = do - reqBody <- Wai.requestBody req - case Aeson.eitherDecodeStrict reqBody of - Left err -> throwIO $ badRequest $ "Could not decode JSON request: " <> Text.pack (show err) - Right val -> return (reqBodyJSONNext rbj val) - --- | Produce a response with `IO`. -newtype WithIO next = WithIO { withIONext :: IO next } - -instance Router next => Router (WithIO next) where - tryRoute = tryRouteNextIO withIONext - --- | A parsed "multipart/form-data" request. -type MultiPartData = ([Wai.Param], [Wai.File FilePath]) - --- | Accept a "multipart/form-data" request. --- Files will be stored in a temporary directory and will be deleted --- automatically after the request is processed. -data ReqBodyMultipart a next = ReqBodyMultipart - { reqMultiPartParse :: MultiPartData -> Either String a - , reqMultiPartNext :: a -> next - } - -instance Router next => Router (ReqBodyMultipart a next) where - tryRoute req = flip fmap (tryRoute req) $ \nextRouter rmp cont -> - runResourceT $ withInternalState $ \s -> do - multiPart <- Wai.parseRequestBody (Wai.tempFileBackEnd s) req - case reqMultiPartParse rmp multiPart of - Left err -> throwIO $ badRequest $ "Could not decode form request: " <> Text.pack err - Right val -> nextRouter (reqMultiPartNext rmp val) cont - --- | Useful synonym for dynamic endpoints: accept requests with a given method, compute a JSON response in `IO` and don't cache. -type Endpoint method a = End :> NoCache :> Method method :> WithIO :> a - --- | Handle a "GET" request and produce a "JSON" response, with `IO`. -type Get a = Endpoint "GET" (JSON a) --- | Handle a "POST" request and produce a "JSON" response, with `IO`. -type Post a = Endpoint "POST" (JSON a) - --- | Most `Router`s are really just newtypes. By using `brief`, you can --- construct trees of `Router`s by providing only their inner types, much --- like Servant. -class Abbreviated a where - type Brief a :: * - type instance Brief a = a - brief :: Brief a -> a - default brief :: a -> a - brief = id - -instance Abbreviated Raw where - type Brief Raw = Wai.Application - brief = Raw - -instance Abbreviated RawResponse where - type Brief RawResponse = Wai.Response - brief = RawResponse - -instance Abbreviated next => Abbreviated (End next) where - type Brief (End next) = Brief next - brief = End . brief - -instance Abbreviated next => Abbreviated (Seg seg next) where - type Brief (Seg seg next) = Brief next - brief = Seg . brief - -instance (Abbreviated left, Abbreviated right) => Abbreviated (left :<|> right) where - type Brief (left :<|> right) = Brief left :<|> Brief right - brief (l :<|> r) = brief l :<|> brief r - -instance Abbreviated next => Abbreviated (OneOfSegs segs next) where - type Brief (OneOfSegs segs next) = Brief next - brief = OneOfSegs . brief - -instance Abbreviated next => Abbreviated (Capture a next) where - type Brief (Capture a next) = a -> Brief next - brief = Capture . fmap brief - -instance Abbreviated next => Abbreviated (Method method next) where - type Brief (Method method next) = Brief next - brief = Method . brief - -instance Abbreviated (JSON a) where - type Brief (JSON a) = a - brief = JSON - -instance Abbreviated (ExtraHeaders next) - -instance Abbreviated next => Abbreviated (NoCache next) where - type Brief (NoCache next) = Brief next - brief = NoCache . brief - -instance Abbreviated next => Abbreviated (ReqBodyJSON a next) where - type Brief (ReqBodyJSON a next) = a -> Brief next - brief = ReqBodyJSON . fmap brief - -instance Abbreviated next => Abbreviated (WithIO next) where - type Brief (WithIO next) = IO (Brief next) - brief = WithIO . fmap brief - -instance Abbreviated (ReqBodyMultipart a next) - --- Generic routers - -deriving instance Router r => Router (K1 i r p) -deriving instance Router (f p) => Router (M1 i c f p) - -instance (Router (left p), Router (right p)) => Router ((left :*: right) p) where - tryRoute req = routeLeft <|> routeRight - where - routeLeft = tryRouteNext (\(left :*: _) -> left) req - routeRight = tryRouteNext (\(_ :*: right) -> right) req - --- Error handling - --- | A `Router`-related exception with a corresponding HTTP error code. -data SolgaError = SolgaError - { errorStatus :: HTTP.Status - , errorMessage :: Text.Text - } deriving (Eq, Ord, Show) - -instance Exception SolgaError - --- | Create a @400 Bad Request@ error with a given message. -badRequest :: Text.Text -> SolgaError -badRequest msg = SolgaError - { errorStatus = HTTP.badRequest400 - , errorMessage = msg - } - --- | Create a @404 Not Found@ error with a given message. -notFound :: Text.Text -> SolgaError -notFound msg = SolgaError - { errorStatus = HTTP.notFound404 - , errorMessage = msg - } +import Solga.Core +import Solga.Router diff --git a/stack.yaml b/stack.yaml index 69ac7ac..cb18ee9 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,16 +1,19 @@ # For more information, see: https://github.com/commercialhaskell/stack/blob/release/doc/yaml_configuration.md # Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) -resolver: nightly-2016-11-07 +resolver: nightly-2017-07-31 # Local packages, usually specified by relative directory name packages: - 'solga' - 'solga-swagger' +- 'solga-core' +- 'solga-router' # Packages to be pulled from upstream that are not in the resolver (e.g., acme-missiles-0.3) extra-deps: -- safe-exceptions-0.1.1.0 +- swagger2-2.1.4.1 +- http-media-0.7.1.1 # Override default flag values for local packages and extra-deps flags: {}