typesafe-hs-0.1.0.0: test/TypeSafe/Conformance/SchemaTest.hs
{-# LANGUAGE ExistentialQuantification #-}
module TypeSafe.Conformance.SchemaTest (tests, excludedContentTypes, setAt) where
import Control.Monad (forM_)
import Data.Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (parseEither)
import Data.Either (isLeft)
import Data.List (nub, sort)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Map.Strict qualified as Map
import Data.Proxy (Proxy (..))
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector qualified as V
import Test.QuickCheck (Arbitrary, Gen, arbitrary, forAll, generate, vectorOf)
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck (counterexample, testProperty, (===))
import TypeSafe.Conformance.JsonSchema
import TypeSafe.Test.Arbitrary
import TypeSafe.Wire
data Binding
= forall a.
(Arbitrary a, ToJSON a, FromJSON a, Eq a, Show a) =>
Binding Text (Proxy a) RoundTrip [FieldBinding a]
data RoundTrip = ExactRoundTrip | JsonRoundTrip
data FieldBinding a = FieldBinding Text (a -> Maybe Value)
bindings :: [Binding]
bindings =
[ Binding "SystemOneRequest" (Proxy @SystemOneRequest) JsonRoundTrip [FieldBinding "model" (\r -> Just (toJSON r.model)), FieldBinding "state" (\r -> Just (toJSON r.state)), FieldBinding "questions" (\r -> Just (object [Key.fromText n .= q | (QuestionName n, q) <- V.toList r.questions])), FieldBinding "assets" (\r -> if Map.null r.assets then Nothing else Just (toJSON r.assets))],
Binding "Question" (Proxy @Question) JsonRoundTrip [],
Binding "NoulQuestion" (Proxy @NoulQuestion) ExactRoundTrip [FieldBinding "instructions" (\r -> toJSON <$> r.instructions), FieldBinding "criteria" (\r -> toJSON <$> r.criteria)],
Binding "NoulCriteria" (Proxy @NoulCriteria) ExactRoundTrip [FieldBinding "true" (\r -> toJSON <$> r.true), FieldBinding "false" (\r -> toJSON <$> r.false)],
Binding "ChoiceQuestion" (Proxy @ChoiceQuestion) JsonRoundTrip [FieldBinding "instructions" (\r -> toJSON <$> r.instructions), FieldBinding "criteria" (\r -> Just (object [Key.fromText k .= v | (k, v) <- V.toList r.criteria]))],
Binding "ScoreQuestion" (Proxy @ScoreQuestion) ExactRoundTrip [FieldBinding "instructions" (\r -> toJSON <$> r.instructions), FieldBinding "criteria" (\r -> Just (toJSON r.criteria))],
Binding "BoxQuestion" (Proxy @BoxQuestion) ExactRoundTrip [FieldBinding "instructions" (\r -> toJSON <$> r.instructions)],
Binding "Asset" (Proxy @Asset) ExactRoundTrip [FieldBinding "format" (\r -> toJSON <$> r.format), FieldBinding "data_base64" (\r -> toJSON <$> r.dataBase64), FieldBinding "part" (\r -> toJSON <$> r.part)],
Binding "SystemOneResponse" (Proxy @SystemOneResponse) ExactRoundTrip [FieldBinding "model" (\r -> Just (toJSON r.model)), FieldBinding "answers" (\r -> Just (toJSON r.answers)), FieldBinding "usage" (\r -> Just (toJSON r.usage))],
Binding "Answer" (Proxy @Answer) ExactRoundTrip [],
Binding "NoulAnswer" (Proxy @NoulAnswer) ExactRoundTrip [FieldBinding "noul" (\r -> Just (toJSON r.noul))],
Binding "ChoiceAnswer" (Proxy @ChoiceAnswer) ExactRoundTrip [FieldBinding "choice" (\r -> Just (toJSON r.choice)), FieldBinding "confidence" (\r -> Just (toJSON r.confidence)), FieldBinding "probabilities" (\r -> Just (toJSON r.probabilities))],
Binding "ScoreAnswer" (Proxy @ScoreAnswer) ExactRoundTrip [FieldBinding "score" (\r -> Just (toJSON r.score)), FieldBinding "confidence" (\r -> Just (toJSON r.confidence)), FieldBinding "legend" (\r -> Just (toJSON r.legend)), FieldBinding "probabilities" (\r -> Just (toJSON r.probabilities))],
Binding "BoxAnswer" (Proxy @BoxAnswer) ExactRoundTrip [FieldBinding "box" (\r -> Just (toJSON r.box)), FieldBinding "confidence" (\r -> Just (toJSON r.confidence))],
Binding "BoxCoordinates" (Proxy @BoxCoordinates) ExactRoundTrip [FieldBinding "top" (\r -> Just (toJSON r.top)), FieldBinding "bottom" (\r -> Just (toJSON r.bottom)), FieldBinding "left" (\r -> Just (toJSON r.left)), FieldBinding "right" (\r -> Just (toJSON r.right))],
Binding "Usage" (Proxy @Usage) ExactRoundTrip [FieldBinding "input_tokens" (\r -> Just (toJSON r.inputTokens)), FieldBinding "output_tokens" (\r -> Just (toJSON r.outputTokens))],
Binding "ModelMetadata" (Proxy @ModelMetadata) ExactRoundTrip [FieldBinding "name" (\r -> Just (toJSON r.name)), FieldBinding "description" (\r -> Just (toJSON r.description)), FieldBinding "release_date" (\r -> Just (toJSON r.releaseDate))],
Binding "ModelMetadataList" (Proxy @ModelMetadataList) ExactRoundTrip [FieldBinding "models" (\r -> Just (toJSON r.models))],
Binding "HTTPValidationError" (Proxy @HTTPValidationError) ExactRoundTrip [FieldBinding "detail" (\r -> toJSON <$> r.detail)],
Binding "ValidationError" (Proxy @ValidationError) ExactRoundTrip [FieldBinding "loc" (\r -> Just (toJSON r.loc)), FieldBinding "msg" (\r -> Just (toJSON r.msg)), FieldBinding "type" (\r -> Just (toJSON r.type_)), FieldBinding "input" (\r -> toJSON <$> r.input), FieldBinding "ctx" (\r -> toJSON <$> r.ctx)],
Binding "HTTPBadRequestError" (Proxy @HTTPBadRequestError) ExactRoundTrip [FieldBinding "detail" (\r -> Just (toJSON r.detail))]
]
excludedContentTypes :: [(Text, Text, Text, Text)]
-- Remove this exclusion when docs/plans/7-send-images-with-multipart-system-one-requests.md is accepted.
excludedContentTypes = [("POST", "/v1/systemone", "request", "multipart/form-data")]
contentErrors :: Spec -> [(Text, Text, Text, Text)]
contentErrors spec =
[entry | entry@(_, _, _, ct) <- operationContentTypes spec, ct /= "application/json", entry `notElem` excludedContentTypes]
<> [entry | entry <- excludedContentTypes, entry `notElem` operationContentTypes spec]
setAt :: [Text] -> Value -> Value -> Value
setAt [] replacement _ = replacement
setAt (k : ks) replacement (Object o) = Object (KM.insert (Key.fromText k) (setAt ks replacement (maybe (Object KM.empty) id (KM.lookup (Key.fromText k) o))) o)
setAt path replacement _ = setAt path replacement (Object KM.empty)
tests :: Spec -> TestTree
tests spec@(Spec root) =
testGroup "OpenAPI conformance" $
[ testCase "every schema is bound" $ sort [name | Binding name _ _ _ <- bindings] @?= sort (specSchemaNames spec),
testCase "the schema validator understands every keyword in use" $ unsupportedKeywords spec @?= [],
testCase "every OpenAPI field in use is understood" $ unsupportedOpenApiFields spec @?= [],
testCase "every content type is bound or explicitly excluded" $ contentErrors spec @?= [],
testCase "unknown keyword meta-test" $ do
let mutant = specFromValue (setAt ["components", "schemas", "Usage", "properties", "input_tokens", "pattern"] (String "^a") root)
unsupportedKeywords mutant @?= ["pattern"]
assertBool "validator rejects unknown keyword" (not (null (validate mutant (schemaRef "Usage") (toJSON (Usage 1 2))))),
testCase "unknown OpenAPI feature meta-test" $ do
let mutant = specFromValue (setAt ["paths", "/v1/systemone", "post", "callbacks"] (object []) root)
assertBool "callbacks reported" (any (Text.isInfixOf "callbacks") (unsupportedOpenApiFields mutant)),
testCase "unknown content type meta-test" $ do
let mutant = specFromValue (setAt ["paths", "/v1/systemone", "post", "requestBody", "content", "application/x-msgpack"] (object []) root)
assertBool "content type rejected" (not (null (contentErrors mutant))),
testCase "validator asserts integer, enum, bounds and required properties" $ do
validate spec (schemaRef "Usage") (toJSON (Usage 1 2)) @?= []
let invalid schema value = assertBool (show value) (not (null (validate spec schema value)))
invalid (schemaRef "Usage") (object ["input_tokens" .= Number 1.5, "output_tokens" .= Number 1])
invalid (at ["properties", "format"] (maybe Null id (lookupSchema spec "Asset"))) (String "gif")
invalid (schemaRef "ScoreQuestion") (object ["type" .= String "score", "criteria" .= replicate 11 (String "level")])
invalid (schemaRef "ChoiceQuestion") (object ["type" .= String "choice", "criteria" .= object []])
invalid (schemaRef "SystemOneResponse") (object ["model" .= String "m", "usage" .= toJSON (Usage 0 0), "answers" .= object []]),
testCase "constructors match discriminator mappings" $ do
sort (map knownQuestionTag questionSamples) @?= sort (map Just (discriminatorTags spec "Question"))
sort (map knownAnswerTag answerSamples) @?= sort (map Just (discriminatorTags spec "Answer"))
eitherDecode @Question "{\"type\":\"box\"}" @?= Right (QuestionBox (BoxQuestion Nothing)),
testProperty "unknown questions preserve their complete encoded object" $ forAll genUnknownQuestion $ \q ->
fmap toJSON (eitherDecode @Question (encode q)) === Right (toJSON q),
testProperty "unknown answers preserve their complete encoded object" $ forAll genUnknownAnswer $ \a ->
fmap toJSON (eitherDecode @Answer (encode a)) === Right (toJSON a),
testCase "constants match document" $ do
serverUrls spec @?= [defaultBaseUrl]
sort (nub [p | (_, p) <- specOperations spec]) @?= sort ["/" <> Text.intercalate "/" systemOnePath, "/" <> Text.intercalate "/" modelsPath]
sort (nub [n | (_, _, n, _, _) <- operationParameters spec]) @?= ["TypeSafe-Danger-Zone-Images", "TypeSafe-Danger-Zone-Long-Context"]
]
<> map (schemaTests spec) bindings
<> [exampleTest spec m p w name value | (m, p, w, name, value) <- operationExamples spec]
schemaTests :: Spec -> Binding -> TestTree
schemaTests spec (Binding name (_ :: Proxy a) roundTrip fields) =
testGroup (Text.unpack name) $
[ testProperty "encoded values validate" $ \(x :: a) ->
let errors = validate spec (schemaRef name) (toJSON x) in counterexample (unlines errors) (null errors),
testProperty "toEncoding agrees with toJSON" $ \(x :: a) -> eitherDecode @Value (encode x) === Right (toJSON x),
testProperty "decoding inverts encoding" $ \(x :: a) -> case roundTrip of
ExactRoundTrip -> eitherDecode @a (encode x) === Right x
JsonRoundTrip -> fmap toJSON (eitherDecode @a (encode x)) === Right (toJSON x)
]
<> if null properties
then []
else
[ testCase "emits exactly the declared properties" $ do
samples <- generate (vectorOf 500 (arbitrary :: Gen a))
Set.unions [Set.fromList (map fst (entries (toJSON x))) | x <- samples] @?= Set.fromList properties,
testCase "requires the required properties and only those" $ do
samples <- generate (vectorOf 50 (arbitrary :: Gen a))
forM_ samples $ \x -> case toJSON x of
Object o -> forM_ (KM.keys o) $ \key ->
isLeft (parseEither (parseJSON @a) (Object (KM.delete key o))) @?= (Key.toText key `elem` requiredProperties spec name)
_ -> assertFailure "expected object",
testCase "property names map to plain Haskell fields" $ do
let implied = ["type" | name `elem` ["Asset", "NoulQuestion", "ChoiceQuestion", "ScoreQuestion", "BoxQuestion", "NoulAnswer", "ChoiceAnswer", "ScoreAnswer", "BoxAnswer"]]
sort ([key | FieldBinding key _ <- fields] <> implied) @?= sort properties
samples <- generate (vectorOf 50 (arbitrary :: Gen a))
forM_ samples $ \x -> case toJSON x of
Object o -> forM_ fields $ \(FieldBinding key getter) -> KM.lookup (Key.fromText key) o @?= getter x
_ -> assertFailure "expected object",
testCase "property examples validate and assembled objects decode" $ do
let schema = maybe Null id (lookupSchema spec name)
examples = propertyExamples spec name
forM_ examples $ \(key, values) -> forM_ values $ \value -> validate spec (at ["properties", key] schema) value @?= []
let first = [(Key.fromText key, v) | (key, v : _) <- examples]
if all (\key -> KM.member (Key.fromText key) (KM.fromList first)) (requiredProperties spec name)
then do
let value = Object (KM.fromList first)
validate spec (schemaRef name) value @?= []
case parseEither (parseJSON @a) value of
Left err -> assertFailure err
Right _ -> pure ()
else pure ()
]
where
properties = objectProperties spec name
exampleTest :: Spec -> Text -> Text -> Text -> Text -> Value -> TestTree
exampleTest spec method path where_ name value = testCase (Text.unpack (method <> " " <> path <> " " <> where_ <> " example " <> name)) $ do
let schemas = [at ["schema"] media | (m, p, w, "application/json", media) <- mediaObjects spec, m == method, p == path, w == where_]
case schemas of
[schema] -> do
validate spec schema value @?= []
case at ["$ref"] schema of
String ref | Just schemaName <- Text.stripPrefix "#/components/schemas/" ref -> do
let matching = [binding | binding@(Binding n _ _ _) <- bindings, n == schemaName]
case matching of
[Binding _ (_ :: Proxy a) _ _] -> case parseEither (parseJSON @a) value of
Left err -> assertFailure err
Right decoded -> toJSON decoded @?= value
_ -> assertFailure ("unbound example schema " <> Text.unpack schemaName)
_ -> assertFailure "example schema must be a component reference"
_ -> assertFailure "example has no unique media schema"
questionSamples :: [Question]
questionSamples = [QuestionNoul (NoulQuestion Nothing Nothing), QuestionChoice (ChoiceQuestion Nothing (V.singleton ("a", Nothing))), QuestionScore (ScoreQuestion Nothing (ContentText "level" :| [])), QuestionBox (BoxQuestion Nothing)]
answerSamples :: [Answer]
answerSamples = [AnswerNoul (NoulAnswer 0), AnswerChoice (ChoiceAnswer "a" 0 Map.empty), AnswerScore (ScoreAnswer 0 0 Map.empty Map.empty), AnswerBox (BoxAnswer (BoxCoordinates 0 1 0 1) Nothing)]
encodedTag :: (ToJSON a) => a -> Maybe Text
encodedTag value = textValue (at ["type"] (toJSON value))
knownQuestionTag :: Question -> Maybe Text
knownQuestionTag q = case q of
QuestionNoul a -> encodedTag a
QuestionChoice a -> encodedTag a
QuestionScore a -> encodedTag a
QuestionBox a -> encodedTag a
QuestionOther _ _ -> Nothing
knownAnswerTag :: Answer -> Maybe Text
knownAnswerTag a = case a of
AnswerNoul x -> encodedTag x
AnswerChoice x -> encodedTag x
AnswerScore x -> encodedTag x
AnswerBox x -> encodedTag x
AnswerOther _ _ -> Nothing