packages feed

hegel-0.1.0: src/Hegel/Generator/Primitives.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Primitive generators for basic Haskell types.
--
-- All generators in this module use the 'Basic' constructor, sending a CBOR
-- schema to the server and decoding the response with a type-appropriate
-- transform.
module Hegel.Generator.Primitives
  ( -- * Scalar generators
    booleans
  , integers
  , floats
  , text
  , binary
  , just

    -- * Range options
  , RangeOpts (..)
  , defaultRangeOpts

    -- * Float options
  , FloatOpts (..)
  , defaultFloatOpts

    -- * Format generators
  , emails
  , urls
  , dates
  , times
  , datetimes
  , domains
  , ipv4Addresses
  , ipv6Addresses
  , fromRegex
  ) where

import Codec.CBOR.Term (Term (..))
import Data.ByteString (ByteString)
import Data.Default (Default (def))
import Data.Maybe (catMaybes, isJust)
import Data.Text (Text)

import Hegel.Generator (Generator (..))
import Hegel.Generator.Range
  ( RangeOpts (..)
  , defaultRangeOpts
  , validateSizeBounds
  )

-- ----------------------------------------------------------------------------
-- CBOR extraction helpers
-- ----------------------------------------------------------------------------

-- | Extract a 'Bool' from a CBOR term.
extractBool :: Term -> Bool
extractBool (TBool b) = b
extractBool t         = error $ "extractBool: expected TBool, got " ++ show t

-- | Extract an 'Int' from a CBOR term.
extractInt :: Term -> Int
extractInt (TInt n)     = n
extractInt (TInteger n) = fromIntegral n
extractInt t            = error $ "extractInt: expected TInt/TInteger, got " ++ show t

-- | Extract a 'Double' from a CBOR term.
extractFloat :: Term -> Double
extractFloat (THalf f)    = realToFrac f
extractFloat (TFloat f)   = realToFrac f
extractFloat (TDouble f)  = f
extractFloat (TInt n)     = fromIntegral n
extractFloat (TInteger n) = fromIntegral n
extractFloat t            = error $ "extractFloat: expected float term, got " ++ show t

-- | Extract 'Text' from a CBOR term.
extractText :: Term -> Text
extractText (TString s) = s
extractText t           = error $ "extractText: expected TString, got " ++ show t

-- | Extract 'ByteString' from a CBOR term.
extractBytes :: Term -> ByteString
extractBytes (TBytes b) = b
extractBytes t          = error $ "extractBytes: expected TBytes, got " ++ show t

-- ----------------------------------------------------------------------------
-- Scalar generators
-- ----------------------------------------------------------------------------

-- | Generator for boolean values.
--
-- Schema: @{\"type\": \"boolean\"}@
booleans :: Generator Bool
booleans = Basic
  (TMap [(TString "type", TString "boolean")])
  extractBool

-- | Generator for integer values within optional bounds.
--
-- Schema: @{\"type\": \"integer\", \"min_value\"?: N, \"max_value\"?: N}@
integers :: RangeOpts Int -> Generator Int
integers RangeOpts {rangeMin = minVal, rangeMax = maxVal} =
  case (minVal, maxVal) of
    (Just lo, Just hi) | lo > hi ->
      error $ "integers: max_value=" ++ show hi ++ " < min_value=" ++ show lo
    _ -> ()
  `seq` Basic (TMap pairs) extractInt
  where
    pairs = catMaybes
      [ Just (TString "type", TString "integer")
      , fmap (\v -> (TString "min_value", TInt v)) minVal
      , fmap (\v -> (TString "max_value", TInt v)) maxVal
      ]

-- | Options for the floating-point generator.
data FloatOpts = FloatOpts
  { floatBounds        :: !(RangeOpts Double)
  , floatExcludeMin    :: !Bool
  , floatExcludeMax    :: !Bool
  , floatAllowNan      :: !(Maybe Bool)
  , floatAllowInfinity :: !(Maybe Bool)
  } deriving (Eq, Show)

instance Default FloatOpts where
  def = FloatOpts
    { floatBounds        = def
    , floatExcludeMin    = False
    , floatExcludeMax    = False
    , floatAllowNan      = Nothing
    , floatAllowInfinity = Nothing
    }

-- | Default float options: no bounds, no exclusions, nan/infinity determined
-- by bounds. Equivalent to 'def'; new code can use 'def' directly.
defaultFloatOpts :: FloatOpts
defaultFloatOpts = def

-- | Generator for floating-point values.
--
-- Schema: @{\"type\": \"float\", \"allow_nan\": bool, \"allow_infinity\": bool,
-- \"width\": 64, \"exclude_min\": bool, \"exclude_max\": bool,
-- \"min_value\"?: f, \"max_value\"?: f}@
--
-- Defaults follow Hypothesis:
--
-- * @allow_nan@: 'True' only when no bounds are set
-- * @allow_infinity@: 'True' when at most one bound is set
floats :: FloatOpts -> Generator Double
floats FloatOpts
  { floatBounds = RangeOpts {rangeMin = minVal, rangeMax = maxVal}
  , floatExcludeMin = excludeMin
  , floatExcludeMax = excludeMax
  , floatAllowNan = allowNan
  , floatAllowInfinity = allowInfinity
  } =
  -- Validation
  (if effAllowNan && (hasMin || hasMax)
     then error "floats: cannot have allow_nan=True with min_value or max_value"
     else ())
  `seq`
  (case (minVal, maxVal) of
     (Just lo, Just hi) | lo > hi ->
       error $ "floats: no floats between min_value=" ++ show lo
            ++ " and max_value=" ++ show hi
     _ -> ())
  `seq`
  (if effAllowInfinity && hasMin && hasMax
     then error "floats: cannot have allow_infinity=True with both min_value and max_value"
     else ())
  `seq`
  Basic (TMap pairs) extractFloat
  where
    hasMin = isJust minVal
    hasMax = isJust maxVal
    effAllowNan = case allowNan of
      Just v  -> v
      Nothing -> not hasMin && not hasMax
    effAllowInfinity = case allowInfinity of
      Just v  -> v
      Nothing -> not hasMin || not hasMax
    pairs =
      [ (TString "type",           TString "float")
      , (TString "allow_nan",      TBool effAllowNan)
      , (TString "allow_infinity", TBool effAllowInfinity)
      , (TString "exclude_min",    TBool excludeMin)
      , (TString "exclude_max",    TBool excludeMax)
      , (TString "width",          TInt 64)
      ]
      ++ catMaybes
      [ fmap (\v -> (TString "min_value", TDouble v)) minVal
      , fmap (\v -> (TString "max_value", TDouble v)) maxVal
      ]

-- | Generator for Unicode text strings.
--
-- Schema: @{\"type\": \"string\", \"min_size\": N, \"max_size\"?: N}@
text :: RangeOpts Int -> Generator Text
text opts =
  let (ms, maxSize) = validateSizeBounds "text" opts
      pairs = catMaybes
        [ Just (TString "type",     TString "string")
        , Just (TString "min_size", TInt ms)
        , fmap (\v -> (TString "max_size", TInt v)) maxSize
        ]
  in
  Basic (TMap pairs) extractText

-- | Generator for binary byte strings.
--
-- Schema: @{\"type\": \"binary\", \"min_size\": N, \"max_size\"?: N}@
binary :: RangeOpts Int -> Generator ByteString
binary opts =
  let (ms, maxSize) = validateSizeBounds "binary" opts
      pairs = catMaybes
        [ Just (TString "type",     TString "binary")
        , Just (TString "min_size", TInt ms)
        , fmap (\v -> (TString "max_size", TInt v)) maxSize
        ]
  in
  Basic (TMap pairs) extractBytes

-- | Generator that always produces the given constant value.
--
-- Schema: @{\"const\": null}@. The transform ignores the server result.
just :: a -> Generator a
just value = Basic
  (TMap [(TString "const", TNull)])
  (const value)

-- ----------------------------------------------------------------------------
-- Format generators
-- ----------------------------------------------------------------------------

-- | Generator for valid email address strings.
emails :: Generator Text
emails = Basic
  (TMap [(TString "type", TString "email")])
  extractText

-- | Generator for valid URL strings.
urls :: Generator Text
urls = Basic
  (TMap [(TString "type", TString "url")])
  extractText

-- | Generator for ISO 8601 date strings (YYYY-MM-DD).
dates :: Generator Text
dates = Basic
  (TMap [(TString "type", TString "date")])
  extractText

-- | Generator for time strings.
times :: Generator Text
times = Basic
  (TMap [(TString "type", TString "time")])
  extractText

-- | Generator for ISO 8601 datetime strings.
datetimes :: Generator Text
datetimes = Basic
  (TMap [(TString "type", TString "datetime")])
  extractText

-- | Generator for domain name strings.
--
-- If a maximum length is provided, generated domains will not exceed that
-- length. Valid range: 4 to 255.
domains :: Maybe Int -> Generator Text
domains maxLength =
  (case maxLength of
     Just ml | ml < 4 || ml > 255 ->
       error $ "domains: max_length=" ++ show ml ++ " must be between 4 and 255"
     _ -> ())
  `seq`
  Basic (TMap pairs) extractText
  where
    pairs = catMaybes
      [ Just (TString "type", TString "domain")
      , fmap (\ml -> (TString "max_length", TInt ml)) maxLength
      ]

-- | Generator for IPv4 address strings (dotted decimal).
ipv4Addresses :: Generator Text
ipv4Addresses = Basic
  (TMap [(TString "type", TString "ipv4")])
  extractText

-- | Generator for IPv6 address strings (colon hex).
ipv6Addresses :: Generator Text
ipv6Addresses = Basic
  (TMap [(TString "type", TString "ipv6")])
  extractText

-- | Generator for strings matching a regular expression.
--
-- When @fullmatch@ is 'True', the entire string must match the pattern.
-- When 'False', a substring match suffices.
fromRegex :: Text -> Bool -> Generator Text
fromRegex regexPattern fullmatch = Basic
  (TMap
    [ (TString "type",      TString "regex")
    , (TString "pattern",   TString regexPattern)
    , (TString "fullmatch", TBool fullmatch)
    ])
  extractText