wai-middleware-validation-0.1.0.0: src/Network/Wai/Middleware/Validation/Internal.hs
{-# LANGUAGE OverloadedStrings #-}
module Network.Wai.Middleware.Validation.Internal where
import Control.Lens (at, (^.), (^?), _Just)
import Data.Aeson (Value, decode)
import qualified Data.ByteString.Lazy as L
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, keys)
import qualified Data.Map.Strict as M
import Data.OpenApi (HttpStatusCode, OpenApi, Operation, PathItem,
Referenced, Schema, components, content, default_,
paths, requestBodies, requestBody, responses,
schema, schemas, validateJSON, _pathItemDelete,
_pathItemGet, _pathItemPatch, _pathItemPost,
_pathItemPut)
import Data.OpenApi.Schema.Generator (dereference)
import qualified Data.Text as T
import Network.HTTP.Types (StdMethod (DELETE, GET, PATCH, POST, PUT))
import System.FilePath (splitDirectories)
--
-- For reverse look up of path
-- https://swagger.io/specification/#path-templating-matching
--
data TemplatedPathComponent = Exact FilePath | ParameterValue deriving (Show)
instance Eq TemplatedPathComponent where
Exact l == Exact r = l == r
_ == _ = True
instance Ord TemplatedPathComponent where
compare (Exact l) (Exact r) = compare l r
compare _ _ = EQ
-- | Convert FilePath to TemplatedPathComponent.
--
-- >>> toTemplatedPathComponent "foo"
-- Exact "foo"
-- >>> toTemplatedPathComponent "{foo}"
-- ParameterValue
toTemplatedPathComponent :: FilePath -> TemplatedPathComponent
toTemplatedPathComponent s
| not (null s) && head s == '{' && last s == '}' = ParameterValue
| otherwise = Exact s
type TemplatedPath = [TemplatedPathComponent]
-- | Convert FilePath to TemplatedPath.
--
-- >>> toTemplatedPath "/foo/{fooId}"
-- [Exact "/",Exact "foo",ParameterValue]
-- >>> toTemplatedPath "/bar/{barId}/baz/{bazId}"
-- [Exact "/",Exact "bar",ParameterValue,Exact "baz",ParameterValue]
toTemplatedPath :: FilePath -> TemplatedPath
toTemplatedPath p = map toTemplatedPathComponent $ splitDirectories p
type PathMap = M.Map TemplatedPath FilePath
-- | Convert list of FilePath to PathMap.
--
-- >>> makePathMap ["/foo", "/foo/{fooId}"]
-- fromList [([Exact "/",Exact "foo"],"/foo"),([Exact "/",Exact "foo",ParameterValue],"/foo/{fooId}")]
makePathMap :: [FilePath] -> PathMap
makePathMap ps = M.fromList $ zip (map toTemplatedPath ps) ps
-- | Look up a path (including a templated path) from PathMap.
--
-- >>> lookupDefinedPath "/foo" (makePathMap ["/foo", "/foo/{fooId}"])
-- Just "/foo"
-- >>> lookupDefinedPath "/foo/1" (makePathMap ["/foo", "/foo/{fooId}"])
-- Just "/foo/{fooId}"
-- >>> lookupDefinedPath "/bar" (makePathMap ["/foo", "/foo/{fooId}"])
-- Nothing
lookupDefinedPath :: FilePath -> PathMap -> Maybe FilePath
lookupDefinedPath realPath = M.lookup (toTemplatedPath realPath)
data ApiDefinition = ApiDefinition
{ getOpenApi :: OpenApi
, getPathMap :: PathMap
} deriving (Eq, Show)
-- | Create ApiDefinition instance from API document.
--
toApiDefinition :: L.ByteString -> Maybe ApiDefinition
toApiDefinition openApiJson = ApiDefinition <$> mOpenApi <*> mPathMap
where
mOpenApi = decode openApiJson :: Maybe OpenApi
mKeys = keys <$> (mOpenApi ^? _Just . paths)
mPathMap = case mKeys of
-- OpenAPI Object must have `paths`
-- https://swagger.io/specification/#openapi-object
Just [] -> Nothing
_ -> makePathMap <$> mKeys
newtype BodySchema = BodySchema { toReferencedSchema :: Referenced Schema } deriving (Eq, Show)
-- | Get request body schema.
--
getRequestBodySchema :: ApiDefinition -> StdMethod -> FilePath -> Maybe BodySchema
getRequestBodySchema a DELETE p = BodySchema <$> getRequestBodyReferencedSchema a _pathItemDelete p
getRequestBodySchema a GET p = BodySchema <$> getRequestBodyReferencedSchema a _pathItemGet p
getRequestBodySchema a PATCH p = BodySchema <$> getRequestBodyReferencedSchema a _pathItemPatch p
getRequestBodySchema a POST p = BodySchema <$> getRequestBodyReferencedSchema a _pathItemPost p
getRequestBodySchema a PUT p = BodySchema <$> getRequestBodyReferencedSchema a _pathItemPut p
getRequestBodySchema _ _ _ = Nothing
getRequestBodyReferencedSchema :: ApiDefinition -> (PathItem -> Maybe Operation) -> FilePath -> Maybe (Referenced Schema)
getRequestBodyReferencedSchema apiDef pathItemMethod realPath =
let
openApi = getOpenApi apiDef
mDefinitionsRequestBody = openApi ^? components . requestBodies
mOperation = getPathItem apiDef realPath >>= pathItemMethod
mReferencedRequestBody = mOperation ^? _Just . requestBody . _Just
mRequestBody = dereference <$> mDefinitionsRequestBody <*> mReferencedRequestBody
mReferencedSchema = mRequestBody ^? _Just . content . at "application/json" . _Just . schema . _Just
in
mReferencedSchema
-- | Get response body schema.
--
getResponseBodySchema :: ApiDefinition -> StdMethod -> FilePath -> Int -> Maybe BodySchema
getResponseBodySchema a DELETE p s = BodySchema <$> getResponseBodyReferencedSchema a _pathItemDelete p s
getResponseBodySchema a GET p s = BodySchema <$> getResponseBodyReferencedSchema a _pathItemGet p s
getResponseBodySchema a PATCH p s = BodySchema <$> getResponseBodyReferencedSchema a _pathItemPatch p s
getResponseBodySchema a POST p s = BodySchema <$> getResponseBodyReferencedSchema a _pathItemPost p s
getResponseBodySchema a PUT p s = BodySchema <$> getResponseBodyReferencedSchema a _pathItemPut p s
getResponseBodySchema _ _ _ _ = Nothing
getResponseBodyReferencedSchema :: ApiDefinition -> (PathItem -> Maybe Operation) -> FilePath -> Int -> Maybe (Referenced Schema)
getResponseBodyReferencedSchema apiDef pathItemMethod realPath statusCode =
let
openApi = getOpenApi apiDef
mDefinitionsResponse = openApi ^? components . responses
mOperation = getPathItem apiDef realPath >>= pathItemMethod
mResponses = mOperation ^? _Just . responses
mReferencedResponse = case mResponses ^? _Just . at statusCode . _Just of
Just rr -> Just rr
Nothing -> mResponses ^? _Just . default_ . _Just
mResponse = dereference <$> mDefinitionsResponse <*> mReferencedResponse
mReferencedSchema = mResponse ^? _Just . content . at "application/json" . _Just . schema . _Just
in
mReferencedSchema
getPathItem :: ApiDefinition -> FilePath -> Maybe PathItem
getPathItem apiDef realPath = mPath >>= \definedPath -> openApi ^? paths . at definedPath . _Just
where
openApi = getOpenApi apiDef
mPath = lookupDefinedPath realPath $ getPathMap apiDef
-- | Validate JSON document.
--
validateJsonDocument :: ApiDefinition -> BodySchema -> L.ByteString -> Either String [String]
validateJsonDocument apiDef bodySchema dataJson = case decode dataJson :: Maybe Value of
Nothing -> Left "The document is not JSON."
Just val -> case definitionsSchema' of
Nothing -> Left "Schema objects are not defined in the OpenAPI document."
Just ds -> case schema' of
Nothing -> Left "The schema for the data is not defined in the OpenAPI document."
Just s -> Right $ map fixValidationError $ validateJSON ds s val
where
openApi = getOpenApi apiDef
definitionsSchema' = openApi ^? components . schemas
referencedSchema' = toReferencedSchema bodySchema
schema' = (`dereference` referencedSchema') <$> definitionsSchema'
-- | Fix validation error message.
--
-- >>> fixValidationError "Hello, World!"
-- "Hello, World!"
-- >>> fixValidationError "expected JSON value of type OpenApiString"
-- "expected JSON value of type string"
-- >>> fixValidationError "expected JSON value of type OpenApiNumber"
-- "expected JSON value of type number"
-- >>> fixValidationError "expected JSON value of type OpenApiInteger"
-- "expected JSON value of type integer"
-- >>> fixValidationError "expected JSON value of type OpenApiBoolean"
-- "expected JSON value of type boolean"
fixValidationError :: String -> String
fixValidationError msg = T.unpack $ foldr replace' (T.pack msg) replacements
where
replace' (needle, replacement) = T.replace needle replacement
replacements =
-- replace internal type name with OpenAPI standard type name
[ ("OpenApiString", "string")
, ("OpenApiNumber", "number")
, ("OpenApiInteger", "integer")
, ("OpenApiBoolean", "boolean")
]