typesafe-hs-0.1.0.0: test/TypeSafe/Conformance/JsonSchema.hs
-- | Test-only reader and validator for the audited OpenAPI/JSON Schema subset.
module TypeSafe.Conformance.JsonSchema where
import Data.Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Foldable (toList)
import Data.List (nub, sort)
import Data.Maybe (fromMaybe)
import Data.Scientific qualified as Scientific
import Data.Text (Text)
import Data.Text qualified as Text
newtype Spec = Spec Value
loadSpec :: FilePath -> IO Spec
loadSpec path = do
value <- either fail pure =<< eitherDecodeFileStrict path
case value of
Object _ -> pure (Spec value)
_ -> fail "OpenAPI document must be an object"
specFromValue :: Value -> Spec
specFromValue = Spec
at :: [Text] -> Value -> Value
at [] v = v
at (k : ks) (Object o) = maybe Null (at ks) (KM.lookup (Key.fromText k) o)
at _ _ = Null
entries :: Value -> [(Text, Value)]
entries (Object o) = [(Key.toText k, v) | (k, v) <- KM.toList o]
entries _ = []
array :: Value -> [Value]
array (Array xs) = toList xs
array _ = []
textValue :: Value -> Maybe Text
textValue (String t) = Just t
textValue _ = Nothing
specVersion :: Spec -> Maybe Text
specVersion (Spec v) = textValue (at ["info", "version"] v)
operations :: Spec -> [(Text, Text, Value)]
operations (Spec v) = [(Text.toUpper method, path, op) | (path, item) <- entries (at ["paths"] v), (method, op) <- entries item, method `elem` methods]
methods :: [Text]
methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"]
specOperations :: Spec -> [(Text, Text)]
specOperations spec = [(m, p) | (m, p, _) <- operations spec]
specSchemaNames :: Spec -> [Text]
specSchemaNames (Spec v) = map fst (entries (at ["components", "schemas"] v))
lookupSchema :: Spec -> Text -> Maybe Value
lookupSchema (Spec v) name = case at ["components", "schemas"] v of
Object o -> KM.lookup (Key.fromText name) o
_ -> Nothing
schemaRef :: Text -> Value
schemaRef name = object ["$ref" .= ("#/components/schemas/" <> name)]
objectProperties :: Spec -> Text -> [Text]
objectProperties spec name = maybe [] (map fst . entries . at ["properties"]) (lookupSchema spec name)
requiredProperties :: Spec -> Text -> [Text]
requiredProperties spec name = maybe [] (foldMap (maybe [] pure . textValue) . array . at ["required"]) (lookupSchema spec name)
discriminatorTags :: Spec -> Text -> [Text]
discriminatorTags spec name = maybe [] (map fst . entries . at ["discriminator", "mapping"]) (lookupSchema spec name)
propertyExamples :: Spec -> Text -> [(Text, [Value])]
propertyExamples spec name = [(k, array (at ["examples"] s)) | (k, s) <- maybe [] (entries . at ["properties"]) (lookupSchema spec name)]
mediaObjects :: Spec -> [(Text, Text, Text, Text, Value)]
mediaObjects spec =
concat
[ [(m, p, "request", ct, media) | (ct, media) <- entries (at ["requestBody", "content"] op)]
<> [(m, p, status, ct, media) | (status, response) <- entries (at ["responses"] op), (ct, media) <- entries (at ["content"] response)]
| (m, p, op) <- operations spec
]
operationContentTypes :: Spec -> [(Text, Text, Text, Text)]
operationContentTypes spec = [(m, p, w, ct) | (m, p, w, ct, _) <- mediaObjects spec]
operationExamples :: Spec -> [(Text, Text, Text, Text, Value)]
operationExamples spec = [(m, p, w, name, at ["value"] ex) | (m, p, w, "application/json", media) <- mediaObjects spec, (name, ex) <- entries (at ["examples"] media)]
operationParameters :: Spec -> [(Text, Text, Text, Text, Value)]
operationParameters spec = [(m, p, fromMaybe "" (textValue (at ["name"] param)), fromMaybe "" (textValue (at ["in"] param)), at ["schema"] param) | (m, p, op) <- operations spec, param <- array (at ["parameters"] op)]
operationSecurity :: Spec -> [(Text, Text, Value)]
operationSecurity spec = [(m, p, at ["security"] op) | (m, p, op) <- operations spec]
securitySchemes :: Spec -> Value
securitySchemes (Spec v) = at ["components", "securitySchemes"] v
serverUrls :: Spec -> [Text]
serverUrls (Spec v) = [url | s <- array (at ["servers"] v), Just url <- [textValue (at ["url"] s)]]
schemaRoots :: Spec -> [Value]
schemaRoots spec@(Spec v) =
map snd (entries (at ["components", "schemas"] v))
<> [s | (_, _, _, _, s) <- operationParameters spec]
<> [at ["schema"] media | (_, _, _, _, media) <- mediaObjects spec]
schemaNodes :: Value -> [Value]
schemaNodes value@(Object o) =
value
: concat
[ case Key.toText k of
"properties" -> concatMap (schemaNodes . snd) (entries v)
"additionalProperties" -> schemaNodes v
"items" -> schemaNodes v
"anyOf" -> concatMap schemaNodes (array v)
"oneOf" -> concatMap schemaNodes (array v)
"contentSchema" -> schemaNodes v
_ -> []
| (k, v) <- KM.toList o
]
schemaNodes v = [v]
supportedKeywords :: [Text]
supportedKeywords = ["$ref", "type", "const", "enum", "properties", "required", "additionalProperties", "items", "minItems", "maxItems", "minProperties", "maxProperties", "anyOf", "oneOf", "title", "description", "examples", "default", "discriminator", "x-accepts", "contentMediaType", "contentSchema"]
unsupportedKeywords :: Spec -> [Text]
unsupportedKeywords spec = sort . nub $ [k | root <- schemaRoots spec, node <- schemaNodes root, (k, _) <- entries node, k `notElem` supportedKeywords]
unsupportedOpenApiFields :: Spec -> [Text]
unsupportedOpenApiFields spec@(Spec root) =
check "root" ["openapi", "info", "paths", "components", "servers"] root
<> check "info" ["title", "description", "version"] (at ["info"] root)
<> check "components" ["schemas", "securitySchemes"] (at ["components"] root)
<> ["root missing " <> k | k <- ["openapi", "info", "paths", "components"], at [k] root == Null]
<> ["components.schemas must be a nonempty object" | null (specSchemaNames spec)]
<> ["unsupported OpenAPI version" | at ["openapi"] root /= String "3.1.0"]
<> concat [check ("paths." <> p) methods item | (p, item) <- entries (at ["paths"] root)]
<> concat [auditOperation m p op | (m, p, op) <- operations spec]
<> concat
[ check (m <> " " <> p <> " " <> w <> " " <> ct) ["schema", "examples", "encoding"] media
<> concat [check ("example." <> name) ["summary", "description", "value"] ex | (name, ex) <- entries (at ["examples"] media)]
| (m, p, w, ct, media) <- mediaObjects spec
]
<> concat
[ check ("securitySchemes." <> name) ["type", "scheme", "description"] s
<> ["unsupported security scheme " <> name | at ["type"] s /= String "http" || at ["scheme"] s /= String "bearer"]
| (name, s) <- entries (securitySchemes spec)
]
where
check path allowed value =
[path <> "." <> k | (k, _) <- entries value, k `notElem` allowed]
<> [path <> " must be an object" | case value of Object _ -> False; _ -> True]
auditOperation m p op =
let prefix = m <> " " <> p
in check prefix ["summary", "description", "operationId", "parameters", "requestBody", "responses", "security"] op
<> concat
[ check (prefix <> " parameter") ["name", "in", "description", "schema", "example", "required"] param
<> [prefix <> " unsupported parameter location" | at ["in"] param /= String "header"]
| param <- array (at ["parameters"] op)
]
<> [prefix <> " unknown security scheme " <> name | security <- array (at ["security"] op), (name, _) <- entries security, name `notElem` map fst (entries (securitySchemes spec))]
<> (if at ["requestBody"] op == Null then [] else check (prefix <> " requestBody") ["content", "required", "description"] (at ["requestBody"] op))
<> concat [check (prefix <> " response " <> status) ["description", "content"] response | (status, response) <- entries (at ["responses"] op)]
validate :: Spec -> Value -> Value -> [String]
validate spec = go (0 :: Int) "$"
where
go depth path schema value
| depth > 64 = [path <> ": schema reference recursion limit exceeded"]
| otherwise = case schema of
Bool True -> []
Bool False -> [path <> ": no value allowed"]
Object s -> concatMap (keyword (depth + 1) path s value) (KM.toList s)
_ -> [path <> ": malformed schema"]
keyword depth path s value (key, arg) =
let recur = go depth
err message = [path <> ": " <> message]
count predicate message = [path <> ": " <> message | predicate]
in case Key.toText key of
"$ref" -> case arg of
String ref | Just name <- Text.stripPrefix "#/components/schemas/" ref, Just target <- lookupSchema spec name -> recur path target value
_ -> err ("unresolved reference " <> show arg)
"type" -> case arg of
String t -> count (not (hasType t value)) ("expected " <> Text.unpack t)
_ -> err "malformed type"
"const" -> count (value /= arg) "constant mismatch"
"enum" -> case arg of
Array values -> count (value `notElem` toList values) "enum mismatch"
_ -> err "malformed enum"
"properties" -> case (arg, value) of
(Object props, Object obj) -> concat [recur (path <> "." <> Key.toString k) child v | (k, child) <- KM.toList props, Just v <- [KM.lookup k obj]]
_ -> []
"required" -> case (arg, value) of
(Array names, Object obj) -> [path <> ": missing required property " <> Text.unpack name | String name <- toList names, not (KM.member (Key.fromText name) obj)]
_ -> []
"additionalProperties" -> case value of
Object obj ->
let declared = map fst (entries (at ["properties"] (Object s)))
in concat [recur (path <> "." <> Text.unpack k) arg v | (k, v) <- entries (Object obj), k `notElem` declared]
_ -> []
"items" -> case value of
Array xs -> concat [recur (path <> "[" <> show i <> "]") arg v | (i, v) <- zip [0 :: Int ..] (toList xs)]
_ -> []
"minItems" -> case (arg, value) of (Number n, Array xs) -> count (fromIntegral (length xs) < n) "too few items"; _ -> []
"maxItems" -> case (arg, value) of (Number n, Array xs) -> count (fromIntegral (length xs) > n) "too many items"; _ -> []
"minProperties" -> case (arg, value) of (Number n, Object o) -> count (fromIntegral (KM.size o) < n) "too few properties"; _ -> []
"maxProperties" -> case (arg, value) of (Number n, Object o) -> count (fromIntegral (KM.size o) > n) "too many properties"; _ -> []
"anyOf" -> case arg of
Array alts -> count (not (any (null . (\alt -> recur path alt value)) (toList alts))) "no anyOf alternative matches"
_ -> err "malformed anyOf"
"oneOf" -> case arg of
Array alts -> count (length (filter (null . (\alt -> recur path alt value)) (toList alts)) /= 1) "expected exactly one oneOf alternative"
_ -> err "malformed oneOf"
annotation | annotation `elem` ["title", "description", "examples", "default", "discriminator", "x-accepts", "contentMediaType", "contentSchema"] -> []
other -> err ("unsupported JSON Schema keyword " <> show other)
hasType t v = case (t, v) of
("string", String _) -> True
("number", Number _) -> True
("integer", Number n) -> Scientific.isInteger n
("boolean", Bool _) -> True
("object", Object _) -> True
("array", Array _) -> True
("null", Null) -> True
_ -> False