packages feed

openapi-hs-4.0.0: src/Data/OpenApi/Schema/Generator.hs

{-# LANGUAGE OverloadedLists #-}

-- |
-- Module:      Data.OpenApi.Schema.Generator
-- Maintainer:  Nadeem Bitar <nadeem@gmail.com>
-- Stability:   experimental
--
-- QuickCheck generators that produce JSON 'Value's conforming to an OpenAPI
-- 3.1 'Schema'. This is primarily useful for property testing: derive a schema
-- from a type with 'ToSchema', generate random values that satisfy it, and
-- check that they parse back via 'FromJSON' (see 'validateFromJSON').
module Data.OpenApi.Schema.Generator where

import Control.Lens.Operators
import Control.Monad (filterM)
import Data.Aeson
import Data.Aeson.Types
import Data.HashMap.Strict.InsOrd.Compat qualified as M
import Data.Maybe
import Data.OpenApi
import Data.OpenApi.Aeson.Compat (fromInsOrdHashMap)
import Data.OpenApi.Declare
import Data.OpenApi.Internal.Schema.Validation (inferSchemaTypes)
import Data.Proxy
import Data.Scientific
import Data.Set qualified as S
import Data.Text qualified as T
import Data.Vector qualified as V
import Prelude.Compat
import Test.QuickCheck (arbitrary)
import Test.QuickCheck.Gen
import Test.QuickCheck.Property
import Prelude ()

-- | Note: 'schemaGen' may 'error', if schema type is not specified,
-- and cannot be inferred.
schemaGen :: Definitions Schema -> Schema -> Gen Value
schemaGen _ schema
  | Just cases <- schema ^. enum_ = elements cases
schemaGen defns schema
  | Just variants <- schema ^. oneOf = schemaGen defns =<< elements (dereference defns <$> variants)
schemaGen defns schema =
  case singleType =<< schema ^. type_ of
    Nothing ->
      case inferSchemaTypes schema of
        [inferredType] -> schemaGen defns (schema & type_ ?~ OpenApiTypeSingle inferredType)
        -- Gen is not MonadFail
        _ -> error "unable to infer schema type"
    Just OpenApiBoolean -> Bool <$> elements [True, False]
    Just OpenApiNull -> pure Null
    Just OpenApiNumber
      | Just min <- schema ^. minimum_,
        Just max <- schema ^. maximum_ ->
          Number
            . fromFloatDigits
            <$> choose (toRealFloat min, toRealFloat max :: Double)
      | otherwise -> Number . fromFloatDigits <$> (arbitrary :: Gen Double)
    Just OpenApiInteger
      | Just min <- schema ^. minimum_,
        Just max <- schema ^. maximum_ ->
          Number
            . fromInteger
            <$> choose (truncate min, truncate max)
      | otherwise -> Number . fromInteger <$> arbitrary
    Just OpenApiArray
      | Just 0 <- schema ^. maxLength -> pure $ Array V.empty
      | Just prefixSchemas <- schema ^. prefixItems -> do
          -- 3.1 tuple: generate one element positionally per prefix schema, then
          -- any trailing elements permitted by `items` (for the generic tuple
          -- derivation `items` is false, so there are none).
          let prefixLen = length prefixSchemas
          prefixVals <- traverse (schemaGen defns . dereference defns) prefixSchemas
          trailing <- case schema ^. items of
            Just (OpenApiItemsObject ref) -> do
              size <- getSize
              let itemSchema = dereference defns ref
                  minLength' = fromMaybe prefixLen $ fromInteger <$> schema ^. minItems
                  maxLength' = fromMaybe (prefixLen + size) $ fromInteger <$> schema ^. maxItems
                  minTrail = max 0 (minLength' - prefixLen)
                  maxTrail = max minTrail (maxLength' - prefixLen)
              n <- choose (minTrail, maxTrail)
              vectorOf n $ schemaGen defns itemSchema
            -- items: false (or absent) -> no elements beyond the prefix.
            _ -> pure []
          return . Array . V.fromList $ prefixVals ++ trailing
      | Just items <- schema ^. items ->
          case items of
            OpenApiItemsObject ref -> do
              size <- getSize
              let itemSchema = dereference defns ref
                  minLength' = fromMaybe 0 $ fromInteger <$> schema ^. minItems
                  maxLength' = fromMaybe size $ fromInteger <$> schema ^. maxItems
              arrayLength <- choose (minLength', max minLength' maxLength')
              generatedArray <- vectorOf arrayLength $ schemaGen defns itemSchema
              return . Array $ V.fromList generatedArray
            -- items: true  -> any element allowed; items: false -> no elements.
            OpenApiItemsBoolean _ -> pure $ Array V.empty
      | otherwise -> pure $ Array V.empty
    Just OpenApiString -> do
      size <- getSize
      let minLength' = fromMaybe 0 $ fromInteger <$> schema ^. minLength
      let maxLength' = fromMaybe size $ fromInteger <$> schema ^. maxLength
      length <- choose (minLength', max minLength' maxLength')
      str <- vectorOf length arbitrary
      return . String $ T.pack str
    Just OpenApiObject -> do
      size <- getSize
      let props = dereference defns <$> schema ^. properties
          reqKeys = S.fromList $ schema ^. required
          allKeys = S.fromList . M.keys $ schema ^. properties
          optionalKeys = allKeys S.\\ reqKeys
          minProps' =
            fromMaybe (length reqKeys)
              $ fromInteger
              <$> schema
              ^. minProperties
          maxProps' = fromMaybe size $ fromInteger <$> schema ^. maxProperties
      shuffledOptional <- shuffle $ S.toList optionalKeys
      numProps <- choose (minProps', max minProps' maxProps')
      let presentKeys = take numProps $ S.toList reqKeys ++ shuffledOptional
      let presentProps = M.filterWithKey (\k _ -> k `elem` presentKeys) props
      let gens = schemaGen defns <$> presentProps
      additionalGens <- case schema ^. additionalProperties of
        Just (AdditionalPropertiesSchema addlSchema) -> do
          additionalKeys <- sequence . take (numProps - length presentProps) . repeat $ T.pack <$> arbitrary
          return . M.fromList $ zip additionalKeys (repeat . schemaGen defns $ dereference defns addlSchema)
        _ -> return []
      x <- sequence $ gens <> additionalGens
      return . Object $ fromInsOrdHashMap x

-- | Resolve a 'Referenced' value against a set of 'Definitions': return the
-- value inline if present, otherwise look the reference up by name.
--
-- /Partial:/ throws if a 'Ref' names a definition not in the map.
dereference :: Definitions a -> Referenced a -> a
dereference _ (Inline a) = a
dereference defs (Ref (Reference ref)) = fromJust $ M.lookup ref defs

-- | Generate a random JSON 'Value' conforming to the schema derived from @a@
-- via its 'ToSchema' instance. Inherits the partiality of 'schemaGen' (it may
-- 'error' when a schema's type is neither specified nor inferable).
genValue :: (ToSchema a) => Proxy a -> Gen Value
genValue p =
  let (defs, NamedSchema _ schema) = runDeclare (declareNamedSchema p) M.empty
   in schemaGen defs schema

-- | Property that asserts every value generated from @a@'s derived schema
-- parses back successfully through its 'FromJSON' instance — i.e. the schema
-- and the decoder agree.
validateFromJSON :: forall a. (ToSchema a, FromJSON a) => Proxy a -> Property
validateFromJSON p = forAll (genValue p)
  $ \val -> case parseEither parseJSON val of
    Right (_ :: a) -> succeeded
    Left err ->
      failed
        { reason = err
        }