-- Copyright 2020 Fernando Rincon Martin -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. ------------------------------------------------------------------------------- {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} module Network.Wai.Routing.Purescheme.Core.Entity ( EntityResponse , entity , NegotiatedResponse , mapEntity , withCustomNegotiation , withCustomNegotiation' , negotiated , ok , created , notFound , badRequest , entityResponse , requestEntity ) where import Network.Wai.Routing.Purescheme.Core.Basic import Network.Wai.Routing.Purescheme.Core.Internal import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as LBC import qualified Data.ByteString.Lazy as LBS import Data.List (find) import Data.Maybe (fromJust, fromMaybe) import Data.String.Interpolate.IsString (i) import Network.HTTP.Media (matchAccept, mapAccept) import Network.HTTP.Types (Status, ResponseHeaders, notAcceptable406, hAccept, hContentType, statusMessage, badRequest400, unsupportedMediaType415, ok200, created201, notFound404) import Network.Wai (Response, ResponseReceived, responseLBS, requestHeaders, strictRequestBody) data EntityResponse e = EntityResponse Status ResponseHeaders e data NegotiatedResponse = NegotiatedResponse Status ResponseHeaders [(ByteString, LBS.ByteString)] instance HasResponseHeaders (EntityResponse a) where mapResponseHeaders mapf (EntityResponse responseStatus responseHeaders entity) = EntityResponse responseStatus (mapf responseHeaders) entity instance HasResponseHeaders (NegotiatedResponse) where mapResponseHeaders mapf (NegotiatedResponse negotiatedStatus negotiatedHeaders entity) = NegotiatedResponse negotiatedStatus (mapf negotiatedHeaders) entity -- | Entity Accessor entity :: EntityResponse e -> e entity (EntityResponse _ _ e) = e -- | Maps a entity response mapEntity :: (a -> b) -> EntityResponse a -> EntityResponse b mapEntity mapf (EntityResponse responseStatus responseHeaders entity) = EntityResponse responseStatus responseHeaders (mapf entity) -- | Converts an application of NegotiatedResponse to a normal WAI Application -- -- This will reject the request with not acceptable (406) in case the content negotation -- fail -- -- Note: This is going to do the content negotiation after the inner application has repond with -- a NegotiatedResponse. That means, any IO is performed before the conetent negoatiation happen. -- TODO: Find another way to do custom negotiation -- Better to use @'withCustomNegotiation'' withCustomNegotiation :: GenericApplication NegotiatedResponse -> GenericApplication Response withCustomNegotiation inner req respond = inner req processNegotiated where processNegotiated :: NegotiatedResponse -> IO ResponseReceived processNegotiated (NegotiatedResponse responseStatus responseHeaders responses) = let acceptedMediaTypes = fmap fst responses respondUsing (mediaType, payload) = let newHeaders = addOrReplaceHeader responseHeaders (hContentType, mediaType) response = responseLBS responseStatus newHeaders payload in respond response in case lookup hAccept $ requestHeaders req of Nothing -> respondUsing $ head responses Just "*/*" -> respondUsing $ head responses Just accept -> case matchAccept acceptedMediaTypes accept of Nothing -> reject' $ notAcceptableRejection acceptedMediaTypes Just accepted -> respondUsing $ fromJust $ find (\(k, _) -> k == accepted) responses -- | The same than @'withCustomNegotiation' but checking the Accept header before doing any IO withCustomNegotiation' :: [ByteString] -> GenericApplication NegotiatedResponse -> GenericApplication Response withCustomNegotiation' accepted inner req = let doit = withCustomNegotiation inner req in case lookup hAccept $ requestHeaders req of Nothing -> doit Just "*/*" -> doit Just accept -> case matchAccept accepted accept of Nothing -> reject $ notAcceptableRejection accepted Just _ -> doit notAcceptableRejection :: [ByteString] -> Rejection notAcceptableRejection acceptedResponses = Rejection { status = notAcceptable406 , message = [i|#{statusMessage notAcceptable406}: Acceptable media types: #{LBC.intercalate ", " acceptedResponses}|] , priority = 200 , headers = [] } -- | Converts a entity response to a negotiated entity negotiated :: [(ByteString, a -> LBS.ByteString)] -> EntityResponse a -> NegotiatedResponse negotiated accptableResponses (EntityResponse responseStatus responseHeaders entity) = NegotiatedResponse responseStatus responseHeaders (fmap (\(key, v) -> (key, v entity)) accptableResponses) -- TODO: This uses lazyness from haskell so that not all payloads are generated -- | Reads the entity and pass it to provided function -- -- The map provides the accepted media types with the functions that decodes it -- -- As specified in https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1 -- a missing content type header from the request is treated "application/octet-stream" -- -- Note: This will read all the payload in memory and then decode it -- so it can blow up the memory. Better to have a guard on the size of the request requestEntity :: [(ByteString, LBS.ByteString -> Either String a)] -> (a -> GenericApplication b) -> GenericApplication b requestEntity mappings fa req respond = let contentTypeHeader = fromMaybe "application/octet-stream" $ lookup hContentType $ requestHeaders req in case mapAccept mappings contentTypeHeader of Just decodeFunc -> do decodedOrError <- decodeFunc <$> strictRequestBody req case decodedOrError of Left decodeError -> reject' $ decodeErrorRejection decodeError Right decoded -> fa decoded req respond Nothing -> reject' $ unsupportedMediaTypeRejection $ fmap fst mappings decodeErrorRejection :: String -> Rejection decodeErrorRejection reason = Rejection { status = badRequest400 , message = [i|#{statusMessage badRequest400}: Error reading entity: #{reason}|] , priority = 200 , headers = [] } unsupportedMediaTypeRejection :: [ByteString] -> Rejection unsupportedMediaTypeRejection supportedMediaTypes = Rejection { status = unsupportedMediaType415 , message = [i|#{statusMessage unsupportedMediaType415}: Supported Media Types: #{LBC.intercalate ", " supportedMediaTypes}|] , priority = 200 , headers = [] } -- | Creates an entity response with status 200 ok :: a -> EntityResponse a ok = EntityResponse ok200 [] -- | Creates a entity response with status 201 created :: a -> EntityResponse a created = EntityResponse created201 [] -- | Creates an entity response with status 404 notFound :: a -> EntityResponse a notFound = EntityResponse notFound404 [] -- | Creates an entity response with status 400 badRequest :: a -> EntityResponse a badRequest = EntityResponse badRequest400 [] -- | Creates a entity response with the provided status and response headers entityResponse :: Status -> ResponseHeaders -> a -> EntityResponse a entityResponse = EntityResponse