never executed always true always false
1 module PureClaw.Gateway.Routes
2 ( -- * WAI Application
3 mkApp
4 -- * Request/Response types
5 , WebhookRequest (..)
6 , WebhookResponse (..)
7 , ErrorResponse (..)
8 , HealthResponse (..)
9 ) where
10
11 import Data.Aeson
12 import Data.ByteString qualified as BS
13 import Data.ByteString.Lazy qualified as LBS
14 import Data.Text (Text)
15 import Data.Text qualified as T
16 import Data.Text.Encoding qualified as TE
17 import Network.HTTP.Types
18 import Network.Wai
19
20 import PureClaw.Core.Errors
21 import PureClaw.Gateway.Auth
22 import PureClaw.Handles.Log
23 import PureClaw.Security.Pairing
24
25 -- | Health check response.
26 newtype HealthResponse = HealthResponse
27 { _hr_status :: Text
28 }
29 deriving stock (Show, Eq)
30
31 instance ToJSON HealthResponse where
32 toJSON hr = object ["status" .= _hr_status hr]
33
34 -- | Error response (safe for external consumers).
35 newtype ErrorResponse = ErrorResponse
36 { _er_error :: Text
37 }
38 deriving stock (Show, Eq)
39
40 instance ToJSON ErrorResponse where
41 toJSON er = object ["error" .= _er_error er]
42
43 -- | Webhook request body.
44 data WebhookRequest = WebhookRequest
45 { _wr_userId :: Text
46 , _wr_content :: Text
47 }
48 deriving stock (Show, Eq)
49
50 instance FromJSON WebhookRequest where
51 parseJSON = withObject "WebhookRequest" $ \o ->
52 WebhookRequest <$> o .: "userId" <*> o .: "content"
53
54 -- | Webhook response body.
55 newtype WebhookResponse = WebhookResponse
56 { _wrs_status :: Text
57 }
58 deriving stock (Show, Eq)
59
60 instance ToJSON WebhookResponse where
61 toJSON wr = object ["status" .= _wrs_status wr]
62
63 -- | Build a WAI Application from the gateway dependencies.
64 mkApp :: PairingState -> LogHandle -> Application
65 mkApp ps lh request respond = do
66 let method = requestMethod request
67 path = pathInfo request
68 case (method, path) of
69 ("GET", ["health"]) -> handleHealth respond
70 ("POST", ["pair"]) -> handlePair ps lh request respond
71 ("POST", ["webhook"]) -> handleWebhook ps lh request respond
72 _ -> respondError respond status404 "Not found"
73
74 handleHealth :: (Response -> IO ResponseReceived) -> IO ResponseReceived
75 handleHealth respond =
76 respond $ jsonResponse status200 (HealthResponse "ok")
77
78 handlePair :: PairingState -> LogHandle -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
79 handlePair ps lh req respond = do
80 body <- consumeRequestBody req
81 case eitherDecode body of
82 Left _ -> respondError respond status400 "Invalid JSON"
83 Right pairReq -> do
84 let clientId = clientIdFromRequest req
85 result <- handlePairRequest ps clientId pairReq lh
86 case result of
87 Left err -> respondPairingError respond err
88 Right pairResp -> respond $ jsonResponse status200 pairResp
89
90 handleWebhook :: PairingState -> LogHandle -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
91 handleWebhook ps lh req respond =
92 case lookup hAuthorization (requestHeaders req) of
93 Nothing -> do
94 _lh_logWarn lh "Webhook: missing Authorization header"
95 respondError respond status401 (publicErrorText NotAllowedError)
96 Just authHeader -> do
97 authResult <- authenticateRequest ps authHeader lh
98 case authResult of
99 Left _ -> respondError respond status401 (publicErrorText NotAllowedError)
100 Right () -> do
101 body <- consumeRequestBody req
102 case eitherDecode body :: Either String WebhookRequest of
103 Left _ -> respondError respond status400 "Invalid JSON"
104 Right _webhookReq -> do
105 _lh_logInfo lh "Webhook: received message"
106 respond $ jsonResponse status200 (WebhookResponse "received")
107
108 -- | Consume the full request body into a lazy ByteString.
109 consumeRequestBody :: Request -> IO LBS.ByteString
110 consumeRequestBody req = LBS.fromChunks <$> collectChunks
111 where
112 collectChunks = do
113 chunk <- getRequestBodyChunk req
114 if BS.null chunk
115 then pure []
116 else (chunk :) <$> collectChunks
117
118 -- | Build a JSON response with the given status code.
119 jsonResponse :: ToJSON a => Status -> a -> Response
120 jsonResponse st body =
121 responseLBS st [(hContentType, "application/json")] (encode body)
122
123 -- | Build an error response.
124 respondError :: (Response -> IO ResponseReceived) -> Status -> Text -> IO ResponseReceived
125 respondError respond st msg =
126 respond $ jsonResponse st (ErrorResponse msg)
127
128 -- | Map PairingError to HTTP response.
129 respondPairingError :: (Response -> IO ResponseReceived) -> PairingError -> IO ResponseReceived
130 respondPairingError respond InvalidCode = respondError respond status400 "InvalidCode"
131 respondPairingError respond LockedOut = respondError respond status429 "LockedOut"
132 respondPairingError respond CodeExpired = respondError respond status400 "CodeExpired"
133
134 -- | Convert PublicError to user-facing text.
135 publicErrorText :: PublicError -> Text
136 publicErrorText (TemporaryError msg) = msg
137 publicErrorText RateLimitError = "Rate limit exceeded"
138 publicErrorText NotAllowedError = "Not allowed"
139
140 -- | Extract a client identifier from the request (X-Forwarded-For or remote host).
141 clientIdFromRequest :: Request -> Text
142 clientIdFromRequest req =
143 case lookup "X-Forwarded-For" (requestHeaders req) of
144 Just xff -> TE.decodeUtf8 xff
145 Nothing -> T.pack (show (remoteHost req))