{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE QuasiQuotes #-} module HtmxExperiment where import Control.Category qualified as Cat import Control.Exception qualified as Exc import Control.Monad.Logger import Control.Selective (Selective (select)) import Control.Selective qualified as Selective import Data.ByteString.Lazy qualified as Lazy import Data.DList (DList) import Data.Error.Tree import Data.Functor.Compose import Data.List qualified as List import Data.Maybe (maybeToList) import Data.Maybe qualified as Maybe import Data.Monoid qualified as Monoid import Data.Text qualified as Text import FieldParser hiding (nonEmpty) import GHC.TypeLits (KnownSymbol, symbolVal) import IHP.HSX.QQ (hsx) import Label import Multipart (FormValidation (FormValidation), FormValidationResult, MultipartParseT, failFormValidation) import Multipart qualified import Network.HTTP.Types qualified as Http import Network.Wai qualified as Wai import Network.Wai.Handler.Warp qualified as Warp import Network.Wai.Parse qualified as Wai.Extra import Network.Wai.Parse qualified as Wai.Parse import PossehlAnalyticsPrelude import Servant.Multipart qualified as Multipart import ServerErrors (ServerError (..), orUserErrorTree) import Text.Blaze.Html5 (Html, docTypeHtml) import Text.Blaze.Renderer.Utf8 (renderMarkup) import UnliftIO (MonadUnliftIO (withRunInIO)) import Prelude hiding (compare) -- data Routes -- = Root -- | Register -- | RegisterSubmit -- data Router url = Router -- { parse :: Routes.URLParser url, -- print :: url -> [Text] -- } -- routerPathInfo :: Routes.PathInfo a => Router a -- routerPathInfo = -- Router -- { parse = Routes.fromPathSegments, -- print = Routes.toPathSegments -- } -- subroute :: Text -> Router subUrl -> Router subUrl -- subroute path inner = -- Router -- { parse = Routes.segment path *> inner.parse, -- print = \url -> path : inner.print url -- } -- routerLeaf :: a -> Router a -- routerLeaf a = -- Router -- { parse = pure a, -- print = \_ -> [] -- } -- routerToSite :: -- ((url -> [(Text, Maybe Text)] -> Text) -> url -> a) -> -- Router url -> -- Routes.Site url a -- routerToSite handler router = -- Routes.Site -- { handleSite = handler, -- formatPathSegments = (\x -> (x, [])) . router.print, -- parsePathSegments = Routes.parseSegments router.parse -- } -- handlers queryParams = \case -- Root -> "root" -- Register -> "register" -- RegisterSubmit -> "registersubmit" newtype Router handler from to = Router {unRouter :: from -> [Text] -> (Maybe handler, to)} deriving (Functor, Applicative) via ( Compose ((->) from) ( Compose ((->) [Text]) ((,) (Monoid.First handler)) ) ) data Routes r handler = Routes { users :: r (Label "register" handler) } data Endpoint handler subroutes = Endpoint { root :: handler, subroutes :: subroutes } deriving stock (Show, Eq) data Handler = Handler {url :: Text} -- myRoute :: Router () from (Endpoint (Routes (Endpoint ()) Handler) b) -- myRoute = -- root $ do -- users <- fixed "users" () $ fixedFinal @"register" () -- pure $ Routes {..} -- -- | the root and its children -- root :: routes from a -> routes from (Endpoint a b) -- root = todo -- | A fixed sub-route with children fixed :: Text -> handler -> Router handler from a -> Router handler from (Endpoint handler a) fixed route handler inner = Router $ \from -> \case [final] | route == final -> ( Just handler, Endpoint { root = handler, subroutes = (inner.unRouter from []) & snd } ) (this : more) | route == this -> ( (inner.unRouter from more) & fst, Endpoint { root = handler, subroutes = (inner.unRouter from more) & snd } ) _ -> (Nothing, Endpoint {root = handler, subroutes = (inner.unRouter from []) & snd}) -- integer :: -- forall routeName routes from a. -- Router (T2 routeName Integer "more" from) a -> -- Router from (Endpoint () a) -- integer inner = Router $ \case -- (path, []) -> -- runFieldParser Field.signedDecimal path -- (path, more) -> -- inner.unRouter more (runFieldParser Field.signedDecimal path) -- -- | A leaf route -- fixedFinal :: forall route handler from. (KnownSymbol route) => handler -> Router handler from (Label route Handler) -- fixedFinal handler = do -- let route = symbolText @route -- Rounter $ \from -> \case -- [final] | route == final -> (Just handler, label @route (Handler from)) -- _ -> (Nothing, label @route handler) -- | Get the text of a symbol via TypeApplications symbolText :: forall sym. KnownSymbol sym => Text symbolText = do symbolVal (Proxy :: Proxy sym) & stringToText main :: IO () main = runStderrLoggingT @IO $ do withRunInIO @(LoggingT IO) $ \runInIO -> do Warp.run 8080 $ \req respond -> catchServerError respond $ do let respondOk res = Wai.responseLBS Http.ok200 [] (renderMarkup res) let htmlRoot inner = docTypeHtml [hsx| {inner} |] res <- case req & Wai.pathInfo of [] -> pure $ respondOk $ htmlRoot [hsx|
|] ["register"] -> pure $ respondOk $ fullEndpoint req $ \case FullPage -> htmlRoot $ registerForm mempty Snippet -> registerForm mempty ["register", "submit"] -> do FormValidation body <- req & parsePostBody registerFormValidate & runInIO case body of -- if the parse succeeds, ignore any of the data (_, Just a) -> pure $ respondOk $ htmlRoot [hsx|{a}|] (errs, Nothing) -> pure $ respondOk $ htmlRoot $ registerForm errs other -> pure $ respondOk [hsx|no route here at {other}|] respond $ res where catchServerError respond io = Exc.catch io (\(ex :: ServerError) -> respond $ Wai.responseLBS ex.status [] ex.errBody) parsePostBody :: (MonadIO m, MonadThrow m, MonadLogger m) => MultipartParseT Multipart.Mem m b -> Wai.Request -> m b parsePostBody parser req = req & Wai.Extra.parseRequestBodyEx Wai.Extra.defaultParseRequestBodyOptions Wai.Extra.lbsBackEnd & liftIO <&> parseAllAsText <&> first (errorTree "Cannot parse multipart form data into UTF-8 text") >>= orUserErrorTree "Failed parsing post body" >>= Multipart.parseMultipart parser where parseAllAsText :: ([(ByteString, ByteString)], [(ByteString, Wai.Parse.FileInfo Lazy.ByteString)]) -> Either (NonEmpty Error) (Multipart.MultipartData Multipart.Mem) -- our multipart parser expects every form field to be valid Text, so we parse from Utf-8 parseAllAsText (inputsBytes, filesBytes) = validationToEither $ do let asText what b = b & bytesToTextUtf8 & first (errorContext [fmt|"{what & bytesToTextUtf8Lenient}" is not unicode|]) & eitherToListValidation inputs <- inputsBytes & traverse ( \(k, v) -> do k' <- k & asText [fmt|input name {k}|] v' <- v & asText [fmt|value of input key {k}|] pure Multipart.Input { iName = k', iValue = v' } ) files <- filesBytes & traverse ( \(k, f) -> do let fdPayload = f.fileContent k' <- k & asText [fmt|file input name {k}|] fdFileName <- f.fileName & asText [fmt|file input file name {f.fileName}|] fdFileCType <- f.fileContentType & asText [fmt|file input content type {f.fileContentType}|] pure Multipart.FileData { fdInputName = k', .. } ) pure $ Multipart.MultipartData {inputs, files} -- migrate :: IO (Label "numberOfRowsAffected" Natural) -- migrate = -- Init.runAppTest $ do -- runTransaction $ -- execute -- [sql| -- CREATE TABLE IF NOT EXISTS experiments.users ( -- id SERIAL PRIMARY KEY, -- email TEXT NOT NULL, -- registration_pending_token TEXT NULL -- ) -- |] -- () data HsxRequest = Snippet | FullPage fullEndpoint :: Wai.Request -> (HsxRequest -> t) -> t fullEndpoint req act = do let isHxRequest = req & Wai.requestHeaders & List.find (\h -> (h & fst) == "HX-Request") & Maybe.isJust if isHxRequest then act Snippet else act FullPage data FormField = FormField { label_ :: Html, required :: Bool, id_ :: Text, name :: Text, type_ :: Text, placeholder :: Maybe Text } inputHtml :: FormField -> DList FormValidationResult -> Html inputHtml (FormField {..}) validationResults = do let validation = validationResults & toList & mapMaybe ( \v -> if v.formFieldName == name then Just ( T2 (label @"errors" (maybeToList v.hasError)) (label @"originalValue" (Monoid.First (Just v.originalValue))) ) else Nothing ) & mconcat let isFirstError = validationResults & List.find (\res -> Maybe.isJust res.hasError && res.formFieldName == name) & Maybe.isJust [hsx| |] registerForm :: DList FormValidationResult -> Html registerForm validationErrors = let fields = mconcat [ inputHtml $ FormField { label_ = "Your Email:", required = True, id_ = "register_email", name = "email", type_ = "email", placeholder = Just "your@email.com" }, inputHtml $ FormField { label_ = "New password:", required = True, id_ = "register_password", name = "password", type_ = "password", placeholder = Just "hunter2" }, inputHtml $ FormField { label_ = "Repeated password:", required = True, id_ = "register_password_repeated", name = "password_repeated", type_ = "password", placeholder = Just "hunter2" } ] in [hsx|
Register user {fields validationErrors}
|] registerFormValidate :: Applicative m => MultipartParseT w m (FormValidation (T2 "email" Text "password" Text)) registerFormValidate = do let emailFP = FieldParser $ \t -> if | Text.elem '@' t -> Right t | otherwise -> Left [fmt|This is not an email address: "{t}"|] getCompose @(MultipartParseT _ _) @FormValidation $ do email <- Compose $ Multipart.fieldLabel' @"email" "email" emailFP password <- aEqB "password_repeated" "The two password fields must be the same" (Compose $ Multipart.field' "password" Cat.id) (\field -> Compose $ Multipart.field' field Cat.id) pure $ T2 email (label @"password" password) where aEqB field validateErr fCompare fValidate = Selective.fromMaybeS -- TODO: this check only reached if the field itself is valid. Could we combine those errors? (Compose $ pure $ failFormValidation (T2 (label @"formFieldName" field) (label @"originalValue" "")) validateErr) $ do compare <- fCompare validate <- fValidate field pure $ if compare == validate then Just validate else Nothing -- | A lifted version of 'Data.Maybe.fromMaybe'. fromMaybeS :: Selective f => f a -> f (Maybe a) -> f a fromMaybeS ifNothing fma = select ( fma <&> \case Nothing -> Left () Just a -> Right a ) ( do a <- ifNothing pure (\() -> a) )