{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} module Servant.Reason.Internal.Generate where import Prelude hiding ((<$>)) import Control.Lens (to, (^.)) import Data.Int (Int32) import Data.List (nub) import Data.Maybe (catMaybes) import Data.Proxy (Proxy) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Lazy as L import qualified Data.Text.Encoding as T import Reason (ReasonDatatype(..), ReasonPrimitive(..)) import qualified Reason import Servant.API (NoContent (..)) import Servant.Reason.Internal.Foreign (LangReason, getEndpoints) import Servant.Reason.Internal.Orphans () import qualified Servant.Foreign as F import Text.PrettyPrint.Leijen.Text {-| Options to configure how code is generated. -} data ReasonOptions = ReasonOptions { {- | The protocol, host and any path prefix to be used as the base for all requests. Example: @Static "https://mydomain.com/api/v1"@ When @Dynamic@, the generated Reason functions take the base URL as the first argument. -} urlPrefix :: UrlPrefix , reasonExportOptions :: Reason.Options -- ^ Options to pass to reason-export , emptyResponseReasonTypes :: [ReasonDatatype] -- ^ Types that represent an empty Http response. , stringReasonTypes :: [ReasonDatatype] -- ^ Types that represent a String. , intReasonTypes :: [ReasonDatatype] -- ^ Types that represent a Int. , floatReasonTypes :: [ReasonDatatype] -- ^ Types that represent a Float. , boolReasonTypes :: [ReasonDatatype] -- ^ Types that represent a Bool. , charReasonTypes :: [ReasonDatatype] -- ^ Types that represent a Char. } data UrlPrefix = Static T.Text | Dynamic {-| Default options for generating Reason code. The default options are: > { urlPrefix = > Static "" > , reasonExportOptions = > Reason.defaultOptions > , emptyResponseReasonTypes = > [ toReasonType NoContent ] > , stringReasonTypes = > [ toReasonType "" ] > } -} defReasonOptions :: ReasonOptions defReasonOptions = ReasonOptions { urlPrefix = Static "" , reasonExportOptions = Reason.defaultOptions , emptyResponseReasonTypes = [ Reason.toReasonType NoContent , Reason.toReasonType () ] , stringReasonTypes = [ Reason.toReasonType ("" :: String) , Reason.toReasonType ("" :: T.Text) ] , intReasonTypes = [ Reason.toReasonType (0 :: Int) , Reason.toReasonType (0 :: Int32) ] , floatReasonTypes = [ Reason.toReasonType (0 :: Float) ] , boolReasonTypes = [ Reason.toReasonType (False :: Bool) ] , charReasonTypes = [ Reason.toReasonType (' ' :: Char) ] } {-| Default imports required by generated Reason code. You probably want to include this at the top of your generated Reason module. -} defReasonImports :: Text defReasonImports = T.unlines [] {-| Generate Reason code for the API with default options. Returns a list of Reason functions to query your Servant API from Reason. You could spit these out to a file and call them from your Reason code, but you would be better off creating a 'Spec' with the result and using 'specsToDir', which handles the module name for you. -} generateReasonForAPI :: ( F.HasForeign LangReason ReasonDatatype api , F.GenerateList ReasonDatatype (F.Foreign ReasonDatatype api)) => Proxy api -> [Text] generateReasonForAPI = generateReasonForAPIWith defReasonOptions {-| Generate Reason code for the API with custom options. -} generateReasonForAPIWith :: ( F.HasForeign LangReason ReasonDatatype api , F.GenerateList ReasonDatatype (F.Foreign ReasonDatatype api)) => ReasonOptions -> Proxy api -> [Text] generateReasonForAPIWith opts = nub . map docToText . map (generateReasonForRequest opts) . getEndpoints i :: Int i = 4 {-| Generate an Reason function for one endpoint. -} generateReasonForRequest :: ReasonOptions -> F.Req ReasonDatatype -> Doc generateReasonForRequest opts request = funcDef where funcDef = vsep [ -- fnName <+> ":" <+> typeSignature -- , "let" <+> fnName <+> "=" <+> args <+> "=> {" , case letParams of Just params -> indent i (vsep ["let" , indent i params <> ";" , indent i reasonRequest ]) Nothing -> indent i reasonRequest , "}" ] fnName = request ^. F.reqFuncName . to (T.replace "-" "" . F.camelCase) . to stext typeSignature = mkTypeSignature opts request args = mkArgs opts request letParams = mkLetParams opts request reasonRequest = mkRequest opts request mkTypeSignature :: ReasonOptions -> F.Req ReasonDatatype -> Doc mkTypeSignature opts request = (hsep . punctuate " ->" . concat) [ catMaybes [urlPrefixType] , headerTypes , urlCaptureTypes , queryTypes , catMaybes [bodyType, returnType] ] where urlPrefixType :: Maybe Doc urlPrefixType = case (urlPrefix opts) of Dynamic -> Just "String" Static _ -> Nothing reasonTypeRef :: ReasonDatatype -> Doc reasonTypeRef eType = stext (Reason.toReasonTypeRefWith (reasonExportOptions opts) eType) headerTypes :: [Doc] headerTypes = [ header ^. F.headerArg . F.argType . to reasonTypeRef | header <- request ^. F.reqHeaders , isNotCookie header ] urlCaptureTypes :: [Doc] urlCaptureTypes = [ F.captureArg capture ^. F.argType . to reasonTypeRef | capture <- request ^. F.reqUrl . F.path , F.isCapture capture ] queryTypes :: [Doc] queryTypes = [ arg ^. F.queryArgName . F.argType . to reasonTypeRef | arg <- request ^. F.reqUrl . F.queryStr ] bodyType :: Maybe Doc bodyType = fmap reasonTypeRef $ request ^. F.reqBody returnType :: Maybe Doc returnType = do result <- fmap reasonTypeRef $ request ^. F.reqReturnType pure ("Http.Request" <+> parens result) reasonHeaderArg :: F.HeaderArg ReasonDatatype -> Doc reasonHeaderArg header = "header_" <> header ^. F.headerArg . F.argName . to (stext . T.replace "-" "_" . F.unPathSegment) reasonCaptureArg :: F.Segment ReasonDatatype -> Doc reasonCaptureArg segment = "capture_" <> F.captureArg segment ^. F.argName . to (stext . F.unPathSegment) reasonQueryArg :: F.QueryArg ReasonDatatype -> Doc reasonQueryArg arg = "query_" <> arg ^. F.queryArgName . F.argName . to (stext . F.unPathSegment) reasonBodyArg :: Doc reasonBodyArg = "body" isNotCookie :: F.HeaderArg f -> Bool isNotCookie header = header ^. F.headerArg . F.argName . to ((/= "cookie") . T.toLower . F.unPathSegment) mkArgs :: ReasonOptions -> F.Req ReasonDatatype -> Doc mkArgs opts request = (tupled . concat) $ [ -- Dynamic url prefix case urlPrefix opts of Dynamic -> ["urlBase"] Static _ -> [] , -- Headers [ reasonHeaderArg header | header <- request ^. F.reqHeaders , isNotCookie header ] , -- URL Captures [ reasonCaptureArg segment | segment <- request ^. F.reqUrl . F.path , F.isCapture segment ] , -- Query params [ reasonQueryArg arg | arg <- request ^. F.reqUrl . F.queryStr ] , -- Request body maybe [] (const [reasonBodyArg]) (request ^. F.reqBody) ] mkLetParams :: ReasonOptions -> F.Req ReasonDatatype -> Maybe Doc mkLetParams opts request = if null (request ^. F.reqUrl . F.queryStr) then Nothing else Just $ "params =" <$> indent i ("List.filter" <> tupled ["((x) => (String.length(x) > 0))", reasonList params]) where params :: [Doc] params = map paramToDoc (request ^. F.reqUrl . F.queryStr) paramToDoc :: F.QueryArg ReasonDatatype -> Doc paramToDoc qarg = -- something wrong with indentation here... case qarg ^. F.queryArgType of F.Normal -> let toStringSrc' = toStringSrc opts (qarg ^. F.queryArgName . F.argType) in reasonName <$$> indent 2 ("|>" <+> toStringSrc' <$$> "|> Js_global.encodeURIComponent" <$$> "|>" <+> "((x__) => if(String.length(x__) > 0) { " <> dquotes (reasonName <> equals) <+> "++ x__ } else {\"\"})") F.Flag -> "if" <> parens reasonName <+> braces (dquotes (name <> equals)) <+> "else" <+> braces (dquotes empty) F.List -> "String.concat" <> tupled [dquotes "&" ,"List.map((x) => " <> dquotes (name <> "[]=") <> " ++ Js_global.encodeURIComponent(" <> toStringSrc opts (qarg ^. F.queryArgName . F.argType) <> "(x)), " <> reasonName <> ")"] where reasonName = reasonQueryArg qarg name = qarg ^. F.queryArgName . F.argName . to (stext . F.unPathSegment) mkRequest :: ReasonOptions -> F.Req ReasonDatatype -> Doc mkRequest opts request = "Js.Promise." <> parens ( line <> indent i ("Fetch.fetchWithInit" <> tupled [url ,"Fetch.RequestInit.make" <> tupled (catMaybes [Just $ "~method_=" <> method ,case headers of [] -> Nothing _ -> Just $ "~headers=" <$$> indent 2 "Fetch.HeadersInit.makeWithDict(" <$$> indent 3 ("Js.Dict.fromList(") <$$> indent 4 (reasonListOfMaybes headers) <> "))" ,case body of Nothing -> Nothing Just b -> Just $ "~body=" <> b ,Just $ "()"])]) <$$> "|> then_" <> parens ("Fetch.Response." <> expectResponse) <$$> "|> then_" <> parens ("response => (response |>" <+> expectDecoder <> ")")) -- , "timeout =" <$> -- indent i "Nothing" -- ])) where method = request ^. F.reqMethod . to (stext . T.toTitle . T.decodeUtf8) mkHeader header = let headerName = header ^. F.headerArg . F.argName . to (stext . F.unPathSegment) headerArgName = reasonHeaderArg header argType = header ^. F.headerArg . F.argType wrapped = isReasonMaybeType argType toStringSrc' = toStringSrc opts (case argType of (ReasonPrimitive (ROption t)) -> t x -> x) -- toStringSrc = -- if isReasonMaybeStringType opts argType || isReasonStringType opts argType then -- mempty -- else -- " << toString" in "Belt.Option.map" <> parens ((if wrapped then headerArgName else parens ("Some" <> parens headerArgName)) <> ", x => " <> parens (dquotes headerName <> ", x |>" <+> toStringSrc')) headers = (case (request ^. F.reqBody, request ^. F.reqBodyContentType) of (Just _, F.ReqBodyJSON) -> ["Some" <> parens (parens ("\"Content-Type\"" <> comma <> stext "\"application/json\""))] _ -> []) ++ [ mkHeader header | header <- request ^. F.reqHeaders , isNotCookie header ] url = mkUrl opts (request ^. F.reqUrl . F.path) <> mkQueryParams request body = case request ^. F.reqBody of Nothing -> Nothing Just reasonTypeExpr -> let encoderName = Reason.toReasonEncoderRefWith (reasonExportOptions opts) reasonTypeExpr in Just $ "Fetch.BodyInit.make" <> parens ("Js.Json.stringify" <> parens (stext encoderName <> parens reasonBodyArg)) expectResponse = case request ^. F.reqReturnType of Just reasonTypeExpr | isEmptyType opts reasonTypeExpr -> "text" Just reasonTypeExpr -> "json" Nothing -> error "mkHttpRequest: no reqReturnType?" expectDecoder = case request ^. F.reqReturnType of Just reasonTypeExpr | isEmptyType opts reasonTypeExpr -> "((x) => if(String.length(x) != 0) { resolve(Belt_Result.Error(\"Expected the response body to empty\")) } else { resolve(Belt_Result.Ok(x)) })" -- let reasonConstructor = -- Reason.toReasonTypeRefWith (reasonExportOptions opts) reasonTypeExpr -- in -- "Http.expectStringResponse" <$> -- indent i (parens (backslash <> braces " body " <+> "->" <$> -- indent i ("if String.isEmpty body then" <$> -- indent i "Ok" <+> stext reasonConstructor <$> -- "else" <$> -- indent i ("Err" <+> dquotes "Expected the response body to be empty")) <> line)) Just reasonTypeExpr -> stext (Reason.toReasonDecoderRefWith (reasonExportOptions opts) reasonTypeExpr) <+> "|> ((x) => Belt_Result.Ok(x)) |> resolve" Nothing -> error "mkHttpRequest: no reqReturnType?" mkUrl :: ReasonOptions -> [F.Segment ReasonDatatype] -> Doc mkUrl opts segments = "String.concat" <> tupled [dquotes "/", (reasonList) ( case urlPrefix opts of Dynamic -> "urlBase" Static url -> dquotes (stext url) : map segmentToDoc segments)] where segmentToDoc :: F.Segment ReasonDatatype -> Doc segmentToDoc s = case F.unSegment s of F.Static path -> dquotes (stext (F.unPathSegment path)) F.Cap arg -> let -- Don't use "toString" on Reason Strings, otherwise we get extraneous quotes. toStringSrc' = toStringSrc opts (arg ^. F.argType) -- toStringSrc = -- if isReasonStringType opts (arg ^. F.argType) then -- empty -- else -- "|> string_of_" <> stext (Reason.toReasonTypeRef (arg ^. F.argType)) in (reasonCaptureArg s) <+> "|>" <+> toStringSrc' <+> "|>" <+> "Js_global.encodeURIComponent" mkQueryParams :: F.Req ReasonDatatype -> Doc mkQueryParams request = if null (request ^. F.reqUrl . F.queryStr) then empty else line <> "++" <+> align ("if(List.length(params)==0)" <+> braces (dquotes empty) <+> "else" <+> braces (dquotes "?" <+> "++ String.concat" <> tupled [dquotes "&", "params"])) {- | Determines whether we construct an Reason function that expects an empty response body. -} isEmptyType :: ReasonOptions -> ReasonDatatype -> Bool isEmptyType opts reasonTypeExpr = reasonTypeExpr `elem` emptyResponseReasonTypes opts {- | Determines how to stringify a value. -} toStringSrc :: ReasonOptions -> ReasonDatatype -> Doc toStringSrc opts argType -- Don't use "toString" on Reason Strings, otherwise we get extraneous quotes. -- We don't append an operator in this case | isReasonStringType opts argType = stext "((x) => x)" | otherwise = toStringSrcTypes opts argType toStringSrcTypes :: ReasonOptions -> ReasonDatatype -> Doc toStringSrcTypes opts (ReasonPrimitive (ROption argType)) = "((x) => Belt.Option.mapWithDefault(x, \"\", " <> toStringSrcTypes opts argType <> "))" -- "Maybe.map (" <> toStringSrcTypes operator opts argType <> ") |> Maybe.withDefault \"\"" -- [Char] == String so we can just use identity here. -- We can't return `""` here, because this string might be nested in a `Maybe` or `List`. toStringSrcTypes _ (ReasonPrimitive (RList (ReasonPrimitive RChar))) = "((x) => x)" toStringSrcTypes opts (ReasonPrimitive (RList argType)) = toStringSrcTypes opts argType toStringSrcTypes opts argType | isReasonStringType opts argType = "((x) => x)" | isReasonIntType opts argType = "string_of_int" | isReasonFloatType opts argType = "string_of_float" | isReasonBoolType opts argType = "string_of_bool" -- We should change this to return `true`/`false` but this mimics the old behavior. | isReasonCharType opts argType = "string_of_char" | otherwise = error $ "Sorry, we don't support other types than `String`, `Int`, `Float`, `Bool`, and `Char` right now. " <> show argType {- | Determines whether we call `toString` on URL captures and query params of this type in Reason. -} isReasonStringType :: ReasonOptions -> ReasonDatatype -> Bool isReasonStringType opts reasonTypeExpr = reasonTypeExpr `elem` stringReasonTypes opts {- | Determines whether we call `String.fromInt` on URL captures and query params of this type in Reason. -} isReasonIntType :: ReasonOptions -> ReasonDatatype -> Bool isReasonIntType opts reasonTypeExpr = reasonTypeExpr `elem` intReasonTypes opts {- | Determines whether we call `String.fromFloat` on URL captures and query params of this type in Reason. -} isReasonFloatType :: ReasonOptions -> ReasonDatatype -> Bool isReasonFloatType opts reasonTypeExpr = reasonTypeExpr `elem` floatReasonTypes opts {- | Determines whether we convert to `true` or `false` -} isReasonBoolType :: ReasonOptions -> ReasonDatatype -> Bool isReasonBoolType opts reasonTypeExpr = reasonTypeExpr `elem` boolReasonTypes opts {- | Determines whether we call `String.fromChar` on URL captures and query params of this type in Reason. -} isReasonCharType :: ReasonOptions -> ReasonDatatype -> Bool isReasonCharType opts reasonTypeExpr = reasonTypeExpr `elem` charReasonTypes opts {- | Determines whether a type is 'Maybe a' where 'a' is something akin to a 'String'. -} isReasonMaybeStringType :: ReasonOptions -> ReasonDatatype -> Bool isReasonMaybeStringType opts (ReasonPrimitive (ROption reasonTypeExpr)) = reasonTypeExpr `elem` stringReasonTypes opts isReasonMaybeStringType _ _ = False isReasonMaybeType :: ReasonDatatype -> Bool isReasonMaybeType (ReasonPrimitive (ROption _)) = True isReasonMaybeType _ = False -- Doc helpers docToText :: Doc -> Text docToText = L.toStrict . displayT . renderPretty 0.4 100 stext :: Text -> Doc stext = text . L.fromStrict reasonRecord :: [Doc] -> Doc reasonRecord = encloseSep (lbrace <> space) (line <> rbrace) (comma <> space) reasonList :: [Doc] -> Doc reasonList [] = lbracket <> rbracket reasonList ds = lbracket <+> hsep (punctuate (line <> comma) ds) <$> rbracket reasonListOfMaybes :: [Doc] -> Doc reasonListOfMaybes [] = lbracket <> rbracket reasonListOfMaybes ds = "Belt.List.keepMap" <> parens (line <> reasonList ds <> ", x => x")