uri-templater-1.0.0: src/Network/URI/Template/TH.hs
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ViewPatterns #-}
module Network.URI.Template.TH
( -- * Quasi-quoter
uri
-- * Template Haskell derivation
, deriveToTemplateValue
, deriveToTemplateValueWith
-- * Options
, Options(..)
, defaultOptions
-- * Field name modifiers
, camelTo2
, camelTo
) where
import Control.Monad (zipWithM)
import Data.Char (isUpper, toLower, toUpper)
import Data.List
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Vector as V
import Language.Haskell.TH
import Language.Haskell.TH.Quote
import Language.Haskell.TH.Syntax
import Network.URI.Template.Internal
import Network.URI.Template.Parser
import Network.URI.Template.Types
variableNames :: UriTemplate -> [T.Text]
variableNames (UriTemplate segments) = Set.toList $ V.foldr go Set.empty segments
where
go (Literal _) acc = acc
go (Embed _ vs) acc = foldr (Set.insert . variableName) acc vs
segmentToExpr :: TemplateSegment -> Q Exp
segmentToExpr (Literal str) = appE (conE 'Literal) (litE $ StringL $ T.unpack str)
segmentToExpr (Embed m vs) = appE (appE (conE 'Embed) modifier) $ listE $ map variableToExpr vs
where
modifier = do
mname <- lookupValueName (show m)
case mname of
Nothing -> fail (show m ++ " is not a valid modifier")
Just n -> conE n
variableToExpr (Variable varName varModifier) = [|Variable $(litE $ StringL $ T.unpack varName) $(varModifierE varModifier)|]
varModifierE vm = case vm of
Normal -> conE 'Normal
Explode -> conE 'Explode
(MaxLength x) -> appE (conE 'MaxLength) $ litE $ IntegerL $ fromIntegral x
templateToExp :: UriTemplate -> Q Exp
templateToExp tpl@(UriTemplate segments) = [|render' (UriTemplate $(appE (varE 'V.fromList) (listE $ map segmentToExpr $ V.toList segments))) $(templateValues)|]
where
templateValues = listE $ map makePair vns
vns = variableNames tpl
makePair str = [|($(litE $ StringL $ T.unpack str), WrappedValue $ toTemplateValue $(varE $ mkName $ T.unpack str))|]
quasiEval :: String -> Q Exp
quasiEval str = do
l <- location
let parseLoc = loc_module l ++ ":" ++ show (loc_start l)
let res = parseTemplate str
case res of
Left err -> fail $ show err
Right tpl -> templateToExp tpl
-- | Parse a template and create a pattern that matches the exact template structure
-- and binds variables from the template as pattern variables.
--
-- Variables in the template become bound pattern variables that can be used in the
-- pattern match body. For example:
--
-- @
-- case template of
-- [uri|/users/{userId}/posts/{postId}|] -> (userId, postId)
-- _ -> ...
-- @
--
-- This will bind 'userId' and 'postId' as variables containing the variable names
-- from the matched template.
quasiPat :: String -> Q Pat
quasiPat str = do
let res = parseTemplate str
case res of
Left err -> fail $ show err
Right (UriTemplate segments) -> do
-- Create a view pattern that converts Vector to list for pattern matching
-- Pattern: UriTemplate (V.toList -> [segment1, segment2, ...])
let segmentPats = map segmentToPat $ V.toList segments
vecPat <- viewP (varE 'V.toList) (listP segmentPats)
conP 'UriTemplate [return vecPat]
where
segmentToPat :: TemplateSegment -> Q Pat
segmentToPat (Literal txt) = conP 'Literal [litP $ StringL $ T.unpack txt]
segmentToPat (Embed mod vars) = conP 'Embed [modifierPat mod, listP $ map variableToPat vars]
modifierPat :: Modifier -> Q Pat
modifierPat m = do
mname <- lookupValueName (show m)
case mname of
Nothing -> fail (show m ++ " is not a valid modifier")
Just n -> conP n []
variableToPat :: Variable -> Q Pat
variableToPat (Variable name valMod) =
-- Bind the variable name as a pattern variable
conP 'Variable [varP $ mkName $ T.unpack name, valueModifierPat valMod]
valueModifierPat :: ValueModifier -> Q Pat
valueModifierPat Normal = conP 'Normal []
valueModifierPat Explode = conP 'Explode []
valueModifierPat (MaxLength n) = conP 'MaxLength [litP $ IntegerL $ fromIntegral n]
{- | URI quasiquoter supporting expressions and patterns.
__Expression context:__ Interpolates variables into the template at compile time.
@
let userId = 123 :: Int
url = [uri|/users/{userId}/profile|] -- produces "/users/123/profile"
@
__Pattern context:__ Matches against a UriTemplate structure and binds variable names.
@
case parsedTemplate of
[uri|/users/{userId}/posts/{postId}|] -> do
-- userId and postId are now bound to their Text values
print (userId, postId)
_ -> putStrLn "Different template"
@
The pattern match checks that the template has the exact structure specified,
and binds each variable name from the template as a pattern variable of type 'Text'.
__Not supported:__
- Type context: URI templates are runtime values, not types
- Declaration context: Would hardcode variable names, not useful in practice
-}
uri :: QuasiQuoter
uri =
QuasiQuoter
{ quoteExp = quasiEval
, quotePat = quasiPat
, quoteType = \_ -> fail "URI templates cannot be used in type context - they are runtime values, not types"
, quoteDec = \_ -> fail "URI templates cannot be used in declaration context - this would hardcode variable names"
}
-------------------------------------------------------------------------------
-- Template Haskell Derivation
-------------------------------------------------------------------------------
-- | Options that specify how to encode/decode your datatype to/from template values.
--
-- This is designed to be similar to Aeson's 'Options' type.
data Options = Options
{ fieldLabelModifier :: String -> String
-- ^ Function applied to field labels. Handy for removing common record prefixes for example.
, constructorTagModifier :: String -> String
-- ^ Function applied to constructor tags which could be used as keys in associative values.
, omitNothingFields :: Bool
-- ^ If 'True', fields with 'Nothing' values will be omitted from the output.
-- Default is 'False'.
, unwrapUnaryRecords :: Bool
-- ^ If 'True', record constructors with a single field will have their value
-- unwrapped instead of being wrapped in an associative value.
-- Default is 'False'.
}
-- | Default encoding 'Options':
--
-- @
-- 'Options'
-- { 'fieldLabelModifier' = id
-- , 'constructorTagModifier' = id
-- , 'omitNothingFields' = False
-- , 'unwrapUnaryRecords' = False
-- }
-- @
defaultOptions :: Options
defaultOptions = Options
{ fieldLabelModifier = id
, constructorTagModifier = id
, omitNothingFields = False
, unwrapUnaryRecords = False
}
-- | Derive a 'ToTemplateValue' instance for a data type.
--
-- For record types, this generates an 'Associative' template value where field names
-- are used as keys. For example:
--
-- @
-- data Person = Person { personName :: String, personAge :: Int }
-- deriveToTemplateValue ''Person
-- @
--
-- Will generate an instance that converts @Person \"Alice\" 30@ to an associative value
-- like @[(\"personName\", \"Alice\"), (\"personAge\", \"30\")]@.
--
-- Uses 'defaultOptions'.
deriveToTemplateValue :: Name -> Q [Dec]
deriveToTemplateValue = deriveToTemplateValueWith defaultOptions
-- | Like 'deriveToTemplateValue', but lets you customize the encoding options.
--
-- Example usage with custom options:
--
-- @
-- data Person = Person { personName :: String, personAge :: Int }
-- deriveToTemplateValueWith defaultOptions
-- { fieldLabelModifier = drop 6 -- Drops \"person\" prefix
-- } ''Person
-- @
deriveToTemplateValueWith :: Options -> Name -> Q [Dec]
deriveToTemplateValueWith opts name = do
info <- reify name
case info of
TyConI dec -> deriveDec opts dec
_ -> fail $ "Cannot derive ToTemplateValue for " ++ show name ++ ": not a type constructor"
deriveDec :: Options -> Dec -> Q [Dec]
deriveDec opts dec = case dec of
DataD _ name tyVars _ cons _ -> deriveForDataType opts name (map stripTyVarBndr tyVars) cons
NewtypeD _ name tyVars _ con _ -> deriveForDataType opts name (map stripTyVarBndr tyVars) [con]
_ -> fail "Can only derive ToTemplateValue for data and newtype declarations"
-- | Strip the annotation from TyVarBndr to get just the name
stripTyVarBndr :: TyVarBndr flag -> Name
stripTyVarBndr (PlainTV n _) = n
stripTyVarBndr (KindedTV n _ _) = n
deriveForDataType :: Options -> Name -> [Name] -> [Con] -> Q [Dec]
deriveForDataType opts typeName tyVarNames cons = do
let instanceType = foldl AppT (ConT typeName) (map VarT tyVarNames)
-- For now, we only support single-constructor record types
case cons of
[con] -> deriveForSingleCon opts instanceType con
_ -> fail "Currently only single-constructor types are supported for deriveToTemplateValue"
deriveForSingleCon :: Options -> Type -> Con -> Q [Dec]
deriveForSingleCon opts instanceType con = case con of
RecC conName fields -> deriveForRecord opts instanceType conName fields
NormalC conName [] -> deriveForNullary opts instanceType conName
_ -> fail "Currently only record constructors and nullary constructors are supported"
deriveForRecord :: Options -> Type -> Name -> [VarBangType] -> Q [Dec]
deriveForRecord opts instanceType conName fields = do
let fieldsInfo = [(fieldName, fieldType) | (fieldName, _, fieldType) <- fields]
-- Check if this is a unary record and unwrapUnaryRecords is enabled
if unwrapUnaryRecords opts && length fieldsInfo == 1
then deriveForUnaryRecord opts instanceType conName (head fieldsInfo)
else deriveForMultiFieldRecord opts instanceType conName fieldsInfo
deriveForUnaryRecord :: Options -> Type -> Name -> (Name, Type) -> Q [Dec]
deriveForUnaryRecord opts instanceType conName (fieldName, fieldType) = do
valueVar <- newName "x"
-- For unwrapped unary records, we just delegate to the field's ToTemplateValue instance
let toTemplateValueImpl = LamE [ConP conName [] [VarP valueVar]]
(AppE (VarE 'toTemplateValue) (VarE valueVar))
return
[ InstanceD Nothing [] (AppT (ConT ''ToTemplateValue) instanceType)
[ TySynInstD (TySynEqn Nothing (AppT (ConT ''TemplateRep) instanceType) (ConT ''Single))
, FunD 'toTemplateValue [Clause [] (NormalB toTemplateValueImpl) []]
]
]
deriveForMultiFieldRecord :: Options -> Type -> Name -> [(Name, Type)] -> Q [Dec]
deriveForMultiFieldRecord opts instanceType conName fieldsInfo = do
varNames <- mapM (const $ newName "field") fieldsInfo
-- Build the pattern match
let pat = ConP conName [] (map VarP varNames)
-- Build the list of key-value pairs
pairExprs <- zipWithM (mkPairExpr opts) fieldsInfo varNames
let listExpr = ListE pairExprs
let toTemplateValueImpl = LamE [pat] (AppE (ConE 'Associative) listExpr)
return
[ InstanceD Nothing [] (AppT (ConT ''ToTemplateValue) instanceType)
[ TySynInstD (TySynEqn Nothing (AppT (ConT ''TemplateRep) instanceType) (ConT ''Associative))
, FunD 'toTemplateValue [Clause [] (NormalB toTemplateValueImpl) []]
]
]
mkPairExpr :: Options -> (Name, Type) -> Name -> Q Exp
mkPairExpr opts (fieldName, _) varName = do
let originalFieldStr = nameBase fieldName
let modifiedFieldStr = fieldLabelModifier opts originalFieldStr
-- Use T.pack to convert String to Text, which has ToHttpApiData instance
let keyExpr = AppE (ConE 'Single) (AppE (VarE 'T.pack) (LitE (StringL modifiedFieldStr)))
let valueExpr = AppE (VarE 'toTemplateValue) (VarE varName)
return $ TupE [Just keyExpr, Just valueExpr]
deriveForNullary :: Options -> Type -> Name -> Q [Dec]
deriveForNullary opts instanceType conName = do
let constructorStr = nameBase conName
let modifiedStr = constructorTagModifier opts constructorStr
-- Use T.pack to convert String to Text
let toTemplateValueImpl = LamE [ConP conName [] []]
(AppE (ConE 'Single) (AppE (VarE 'T.pack) (LitE (StringL modifiedStr))))
return
[ InstanceD Nothing [] (AppT (ConT ''ToTemplateValue) instanceType)
[ TySynInstD (TySynEqn Nothing (AppT (ConT ''TemplateRep) instanceType) (ConT ''Single))
, FunD 'toTemplateValue [Clause [] (NormalB toTemplateValueImpl) []]
]
]
-------------------------------------------------------------------------------
-- Field name transformation utilities
-------------------------------------------------------------------------------
-- | Converts from camel case to another lower case, separated by the given character.
-- Has two special rules: an acronym (all uppercase) is converted to all lowercase,
-- and the first character (whether uppercase or lowercase) is never prefixed with the separator.
--
-- Example: @camelTo2 \'_\' \"SomeFieldName\" == \"some_field_name\"@
-- Example: @camelTo2 \'_\' \"productName\" == \"product_name\"@
camelTo2 :: Char -> String -> String
camelTo2 _ "" = ""
camelTo2 c (x:xs) = toLower x : go xs
where
go "" = ""
go (u:l:rest) | isUpper u && isUpper l = c : toLower u : toLower l : go rest
go (u:rest) | isUpper u = c : toLower u : go rest
go (l:rest) = toLower l : go rest
-- | Converts from camel case to another lower case, separated by the given character.
-- Consecutive uppercase characters are not handled specially.
--
-- Example: @camelTo \'_\' \"SomeFieldName\" == \"some_field_name\"@
camelTo :: Char -> String -> String
camelTo c = go
where
go "" = ""
go (x:xs) | isUpper x = c : toLower x : go xs
| otherwise = x : go xs