diff --git a/.gitignore b/.gitignore index 4a277591..c2cab955 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ cabal.sandbox.config *.aux *.hp .stack-work/ +stack.yaml.lock diff --git a/src/Data/OpenApi.hs b/src/Data/OpenApi.hs index a9ce8f3d..3b1e775f 100644 --- a/src/Data/OpenApi.hs +++ b/src/Data/OpenApi.hs @@ -30,7 +30,6 @@ module Data.OpenApi ( -- * Re-exports module Data.OpenApi.Lens, - module Data.OpenApi.Optics, module Data.OpenApi.Operation, module Data.OpenApi.ParamSchema, module Data.OpenApi.Schema, diff --git a/src/Data/OpenApi/Declare.hs b/src/Data/OpenApi/Declare.hs index a302bb0a..6158bb9f 100644 --- a/src/Data/OpenApi/Declare.hs +++ b/src/Data/OpenApi/Declare.hs @@ -52,7 +52,7 @@ instance (Applicative m, Monad m, Monoid d) => Applicative (DeclareT d m) where return (mappend d' d'', f x) instance (Applicative m, Monad m, Monoid d) => Monad (DeclareT d m) where - return x = DeclareT (\_ -> pure (mempty, x)) + return = pure DeclareT dx >>= f = DeclareT $ \d -> do ~(d', x) <- dx d ~(d'', y) <- runDeclareT (f x) (mappend d d') diff --git a/src/Data/OpenApi/Internal.hs b/src/Data/OpenApi/Internal.hs index b9be5292..aea8a654 100644 --- a/src/Data/OpenApi/Internal.hs +++ b/src/Data/OpenApi/Internal.hs @@ -14,6 +14,8 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + module Data.OpenApi.Internal where import Prelude () @@ -335,7 +337,9 @@ instance Data MediaType where dataTypeOf _ = mediaTypeData +mediaTypeConstr :: Constr mediaTypeConstr = mkConstr mediaTypeData "MediaType" [] Prefix +mediaTypeData :: DataType mediaTypeData = mkDataType "MediaType" [mediaTypeConstr] instance Hashable MediaType where @@ -1006,12 +1010,12 @@ deriveGeneric ''OpenApiSpecVersion -- ======================================================================= instance Semigroup OpenApiSpecVersion where - (<>) (OpenApiSpecVersion a) (OpenApiSpecVersion b) = OpenApiSpecVersion $ max a b - + (<>) (OpenApiSpecVersion a) (OpenApiSpecVersion b) = OpenApiSpecVersion $ max a b + instance Monoid OpenApiSpecVersion where mempty = OpenApiSpecVersion (makeVersion [3,0,0]) mappend = (<>) - + instance Semigroup OpenApi where (<>) = genericMappend instance Monoid OpenApi where @@ -1282,7 +1286,7 @@ instance FromJSON OAuth2AuthorizationCodeFlow where -- Manual ToJSON instances -- ======================================================================= -instance ToJSON OpenApiSpecVersion where +instance ToJSON OpenApiSpecVersion where toJSON (OpenApiSpecVersion v)= toJSON . showVersion $ v instance ToJSON MediaType where @@ -1456,15 +1460,15 @@ instance FromJSON OpenApiSpecVersion where parseJSON = withText "OpenApiSpecVersion" $ \str -> let validatedVersion :: Either String Version validatedVersion = do - parsedVersion <- readVersion str + parsedVersion <- readVersion str unless ((parsedVersion >= lowerOpenApiSpecVersion) && (parsedVersion <= upperOpenApiSpecVersion)) $ Left ("The provided version " <> showVersion parsedVersion <> " is out of the allowed range >=" <> showVersion lowerOpenApiSpecVersion <> " && <=" <> showVersion upperOpenApiSpecVersion) return parsedVersion - in + in either fail (return . OpenApiSpecVersion) validatedVersion where readVersion :: Text -> Either String Version - readVersion v = case readP_to_S parseVersion (Text.unpack v) of + readVersion v = case readP_to_S parseVersion (Text.unpack v) of [] -> Left $ "Failed to parse as a version string " <> Text.unpack v solutions -> Right (fst . last $ solutions) @@ -1649,7 +1653,7 @@ instance HasSwaggerAesonOptions Encoding where instance HasSwaggerAesonOptions Link where swaggerAesonOptions _ = mkSwaggerAesonOptions "link" -instance AesonDefaultValue Version where +instance AesonDefaultValue Version where defaultValue = Just (makeVersion [3,0,0]) instance AesonDefaultValue OpenApiSpecVersion instance AesonDefaultValue Server diff --git a/src/Data/OpenApi/Internal/AesonUtils.hs b/src/Data/OpenApi/Internal/AesonUtils.hs index 3804ab32..786226a1 100644 --- a/src/Data/OpenApi/Internal/AesonUtils.hs +++ b/src/Data/OpenApi/Internal/AesonUtils.hs @@ -4,6 +4,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ExplicitForAll #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableSuperClasses #-} module Data.OpenApi.Internal.AesonUtils ( -- * Generic functions diff --git a/src/Data/OpenApi/Internal/ParamSchema.hs b/src/Data/OpenApi/Internal/ParamSchema.hs index 75b637a2..a841b3ad 100644 --- a/src/Data/OpenApi/Internal/ParamSchema.hs +++ b/src/Data/OpenApi/Internal/ParamSchema.hs @@ -19,6 +19,7 @@ module Data.OpenApi.Internal.ParamSchema where import Control.Lens import Data.Aeson (ToJSON (..)) +import Data.Kind import Data.Proxy import GHC.Generics @@ -163,7 +164,7 @@ instance ToParamSchema Word64 where -- "minimum": -128, -- "type": "integer" -- } -toParamSchemaBoundedIntegral :: forall a t. (Bounded a, Integral a) => Proxy a -> Schema +toParamSchemaBoundedIntegral :: forall a. (Bounded a, Integral a) => Proxy a -> Schema toParamSchemaBoundedIntegral _ = mempty & type_ ?~ OpenApiInteger & minimum_ ?~ fromInteger (toInteger (minBound :: a)) @@ -310,10 +311,10 @@ instance ToParamSchema UUID where -- ], -- "type": "string" -- } -genericToParamSchema :: forall a t. (Generic a, GToParamSchema (Rep a)) => SchemaOptions -> Proxy a -> Schema +genericToParamSchema :: forall a. (Generic a, GToParamSchema (Rep a)) => SchemaOptions -> Proxy a -> Schema genericToParamSchema opts _ = gtoParamSchema opts (Proxy :: Proxy (Rep a)) mempty -class GToParamSchema (f :: * -> *) where +class GToParamSchema (f :: Type -> Type) where gtoParamSchema :: SchemaOptions -> Proxy f -> Schema -> Schema instance GToParamSchema f => GToParamSchema (D1 d f) where @@ -331,7 +332,7 @@ instance ToParamSchema c => GToParamSchema (K1 i c) where instance (GEnumParamSchema f, GEnumParamSchema g) => GToParamSchema (f :+: g) where gtoParamSchema opts _ = genumParamSchema opts (Proxy :: Proxy (f :+: g)) -class GEnumParamSchema (f :: * -> *) where +class GEnumParamSchema (f :: Type -> Type) where genumParamSchema :: SchemaOptions -> Proxy f -> Schema -> Schema instance (GEnumParamSchema f, GEnumParamSchema g) => GEnumParamSchema (f :+: g) where diff --git a/src/Data/OpenApi/Internal/Schema.hs b/src/Data/OpenApi/Internal/Schema.hs index da56acf0..212b2a40 100644 --- a/src/Data/OpenApi/Internal/Schema.hs +++ b/src/Data/OpenApi/Internal/Schema.hs @@ -24,7 +24,7 @@ module Data.OpenApi.Internal.Schema where import Prelude () import Prelude.Compat -import Control.Lens hiding (allOf) +import Control.Lens hiding (allOf, anyOf) import Data.Data.Lens (template) import Control.Applicative ((<|>)) @@ -43,6 +43,7 @@ import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap import Data.Int import Data.IntSet (IntSet) import Data.IntMap (IntMap) +import Data.Kind import Data.List (sort) import Data.List.NonEmpty.Compat (NonEmpty) import Data.Map (Map) @@ -587,7 +588,7 @@ sketchStrictSchema = go . toJSON where names = objectKeys o -class GToSchema (f :: * -> *) where +class GToSchema (f :: Type -> Type) where gdeclareNamedSchema :: SchemaOptions -> Proxy f -> Schema -> Declare (Definitions Schema) NamedSchema instance {-# OVERLAPPABLE #-} ToSchema a => ToSchema [a] where @@ -623,7 +624,10 @@ instance ToSchema Float where declareNamedSchema = plain . paramSchemaToSc instance (Typeable (Fixed a), HasResolution a) => ToSchema (Fixed a) where declareNamedSchema = plain . paramSchemaToSchema instance ToSchema a => ToSchema (Maybe a) where - declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy a) + declareNamedSchema _ = do + ref <- declareSchemaRef (Proxy @a) + -- NB: using 'oneOf' goes wrong for nested Maybe's as both subschemas match 'null'. + pure $ unnamed $ mempty & anyOf ?~ [Inline $ mempty & type_ ?~ OpenApiNull, ref] instance (ToSchema a, ToSchema b) => ToSchema (Either a b) where -- To match Aeson instance @@ -1016,10 +1020,7 @@ instance {-# OVERLAPPING #-} (Selector s, ToSchema c) => GToSchema (S1 s (K1 i ( instance {-# OVERLAPPABLE #-} (Selector s, GToSchema f) => GToSchema (S1 s f) where gdeclareNamedSchema opts _ = fmap unnamed . withFieldSchema opts (Proxy2 :: Proxy2 s f) True -instance {-# OVERLAPPING #-} ToSchema c => GToSchema (K1 i (Maybe c)) where - gdeclareNamedSchema _ _ _ = declareNamedSchema (Proxy :: Proxy c) - -instance {-# OVERLAPPABLE #-} ToSchema c => GToSchema (K1 i c) where +instance ToSchema c => GToSchema (K1 i c) where gdeclareNamedSchema _ _ _ = declareNamedSchema (Proxy :: Proxy c) instance ( GSumToSchema f @@ -1031,7 +1032,9 @@ instance ( GSumToSchema f gdeclareNamedSumSchema :: GSumToSchema f => SchemaOptions -> Proxy f -> Schema -> Declare (Definitions Schema) NamedSchema gdeclareNamedSumSchema opts proxy _ - | allNullaryToStringTag opts && allNullary = pure $ unnamed (toStringTag sumSchemas) + | allNullaryToStringTag opts && allNullary = pure $ unnamed $ mempty + & type_ ?~ OpenApiString + & enum_ ?~ map (String . fst) sumSchemas | otherwise = do (schemas, _) <- runWriterT declareSumSchema return $ unnamed $ mempty @@ -1040,13 +1043,9 @@ gdeclareNamedSumSchema opts proxy _ declareSumSchema = gsumToSchema opts proxy (sumSchemas, All allNullary) = undeclare (runWriterT declareSumSchema) - toStringTag schemas = mempty - & type_ ?~ OpenApiString - & enum_ ?~ map (String . fst) sumSchemas - type AllNullary = All -class GSumToSchema (f :: * -> *) where +class GSumToSchema (f :: Type -> Type) where gsumToSchema :: SchemaOptions -> Proxy f -> WriterT AllNullary (Declare (Definitions Schema)) [(T.Text, Referenced Schema)] instance (GSumToSchema f, GSumToSchema g) => GSumToSchema (f :+: g) where diff --git a/src/Data/OpenApi/Internal/TypeShape.hs b/src/Data/OpenApi/Internal/TypeShape.hs index 89230e21..cf3a1c6a 100644 --- a/src/Data/OpenApi/Internal/TypeShape.hs +++ b/src/Data/OpenApi/Internal/TypeShape.hs @@ -3,9 +3,11 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} +{-# OPTIONS_GHC -Wno-unticked-promoted-constructors #-} module Data.OpenApi.Internal.TypeShape where +import Data.Kind import Data.Proxy import GHC.Generics import GHC.TypeLits @@ -46,7 +48,7 @@ type family GenericHasSimpleShape t (f :: Symbol) (s :: TypeShape) :: Constraint ) -- | Infer a 'TypeShape' for a generic representation of a type. -type family GenericShape (g :: * -> *) :: TypeShape +type family GenericShape (g :: Type -> Type) :: TypeShape type instance GenericShape (f :*: g) = ProdCombine (GenericShape f) (GenericShape g) type instance GenericShape (f :+: g) = SumCombine (GenericShape f) (GenericShape g) diff --git a/src/Data/OpenApi/Optics.hs b/src/Data/OpenApi/Optics.hs index 3d0a42e8..588b50ee 100644 --- a/src/Data/OpenApi/Optics.hs +++ b/src/Data/OpenApi/Optics.hs @@ -4,6 +4,7 @@ {-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} {-# OPTIONS_GHC -fno-warn-orphans #-} -- | diff --git a/src/Data/OpenApi/Schema/Generator.hs b/src/Data/OpenApi/Schema/Generator.hs index 9cb4014f..951d4345 100644 --- a/src/Data/OpenApi/Schema/Generator.hs +++ b/src/Data/OpenApi/Schema/Generator.hs @@ -34,6 +34,8 @@ schemaGen _ schema | Just cases <- schema ^. enum_ = elements cases schemaGen defns schema | Just variants <- schema ^. oneOf = schemaGen defns =<< elements (dereference defns <$> variants) +schemaGen defns schema + | Just variants <- schema ^. anyOf = schemaGen defns =<< elements (dereference defns <$> variants) schemaGen defns schema = case schema ^. type_ of Nothing -> diff --git a/src/Data/OpenApi/Schema/Validation.hs b/src/Data/OpenApi/Schema/Validation.hs index 9728ceef..fbbdecb8 100644 --- a/src/Data/OpenApi/Schema/Validation.hs +++ b/src/Data/OpenApi/Schema/Validation.hs @@ -34,10 +34,13 @@ import Data.OpenApi.Internal.Schema.Validation -- $setup -- >>> import Control.Lens -- >>> import Data.Aeson +-- >>> import Data.Aeson.QQ.Simple -- >>> import Data.Proxy -- >>> import Data.OpenApi +-- >>> import Data.OpenApi.Declare -- >>> import GHC.Generics -- >>> :set -XDeriveGeneric +-- >>> :set -XQuasiQuotes -- $howto -- @@ -67,24 +70,18 @@ import Data.OpenApi.Internal.Schema.Validation -- $maybe -- --- Because @'Maybe' a@ has the same schema as @a@, validation --- generally fails for @null@ JSON: --- --- >>> validateToJSON (Nothing :: Maybe String) --- ["expected JSON value of type OpenApiString"] --- >>> validateToJSON ([Just "hello", Nothing] :: [Maybe String]) --- ["expected JSON value of type OpenApiString"] --- >>> validateToJSON (123, Nothing :: Maybe String) --- ["expected JSON value of type OpenApiString"] --- --- However, when @'Maybe' a@ is a type of a record field, --- validation takes @'required'@ property of the @'Schema'@ --- into account: +-- The behavior is in line with "aeson" behavior for derived instances. +-- When @'Maybe' a@ is a type of a record field, +-- validation accepts both ommited field and null as a field value: -- -- >>> data Person = Person { name :: String, age :: Maybe Int } deriving Generic -- >>> instance ToJSON Person -- >>> instance ToSchema Person --- >>> validateToJSON (Person "Nick" (Just 24)) +-- >>> let (defs, sch) = runDeclare (declareSchema (Proxy :: Proxy Person)) mempty +-- >>> let validate = validateJSON defs sch +-- >>> validate [aesonQQ|{"name" : "Nick", "age" : 18}|] +-- [] +-- >>> validate [aesonQQ|{"name" : "Nick", "age" : null}|] -- [] --- >>> validateToJSON (Person "Nick" Nothing) +-- >>> validate [aesonQQ|{"name" : "Nick"}|] -- [] diff --git a/stack.yaml b/stack.yaml index 7ab2f4e0..16179a8d 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,9 +1,5 @@ -resolver: lts-16.31 +resolver: lts-21.24 packages: - '.' -extra-deps: -- optics-core-0.3 -- optics-th-0.3 -- optics-extra-0.3 -- indexed-profunctors-0.1 -- insert-ordered-containers-0.2.3.1 +ghc-options: + $locals: -Wall -Wno-unused-imports -Wno-dodgy-imports -Wno-name-shadowing diff --git a/test/Data/OpenApi/CommonTestTypes.hs b/test/Data/OpenApi/CommonTestTypes.hs index 99a39ec7..65480041 100644 --- a/test/Data/OpenApi/CommonTestTypes.hs +++ b/test/Data/OpenApi/CommonTestTypes.hs @@ -205,7 +205,14 @@ personSchemaJSON = [aesonQQ| { "name": { "type": "string" }, "phone": { "type": "integer" }, - "email": { "type": "string" } + "email": + { + "anyOf" : + [ + { "type" : "null" }, + { "type": "string" } + ] + } }, "required": ["name", "phone"] } @@ -867,7 +874,14 @@ singleMaybeFieldSchemaJSON = [aesonQQ| "type": "object", "properties": { - "singleMaybeField": { "type": "string" } + "singleMaybeField": + { + "anyOf" : + [ + { "type" : "null" }, + { "type": "string" } + ] + } } } |] diff --git a/test/Data/OpenApi/Schema/GeneratorSpec.hs b/test/Data/OpenApi/Schema/GeneratorSpec.hs index 092673f6..7b3891cb 100644 --- a/test/Data/OpenApi/Schema/GeneratorSpec.hs +++ b/test/Data/OpenApi/Schema/GeneratorSpec.hs @@ -68,7 +68,7 @@ spec = do prop "T.Text" $ shouldValidate (Proxy :: Proxy T.Text) prop "TL.Text" $ shouldValidate (Proxy :: Proxy TL.Text) prop "[String]" $ shouldValidate (Proxy :: Proxy [String]) - -- prop "(Maybe [Int])" $ shouldValidate (Proxy :: Proxy (Maybe [Int])) + prop "(Maybe [Int])" $ shouldValidate (Proxy :: Proxy (Maybe [Int])) prop "(IntMap String)" $ shouldValidate (Proxy :: Proxy (IntMap String)) prop "(Set Bool)" $ shouldValidate (Proxy :: Proxy (Set Bool)) prop "(NonEmpty Bool)" $ shouldValidate (Proxy :: Proxy (NonEmpty Bool)) diff --git a/test/Data/OpenApi/Schema/ValidationSpec.hs b/test/Data/OpenApi/Schema/ValidationSpec.hs index 8b66189d..981e6ced 100644 --- a/test/Data/OpenApi/Schema/ValidationSpec.hs +++ b/test/Data/OpenApi/Schema/ValidationSpec.hs @@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE PackageImports #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -8,11 +9,13 @@ module Data.OpenApi.Schema.ValidationSpec where import Control.Applicative import Control.Lens ((&), (.~), (?~)) +import Control.Monad import Data.Aeson #if MIN_VERSION_aeson(2,0,0) import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap #endif +import Data.Aeson.QQ.Simple import Data.Aeson.Types import Data.Hashable (Hashable) import Data.HashMap.Strict (HashMap) @@ -45,6 +48,13 @@ import Test.QuickCheck.Instances () shouldValidate :: (ToJSON a, ToSchema a) => Proxy a -> a -> Bool shouldValidate _ x = validateToJSON x == [] +shouldValidateValue :: (ToSchema a) => Proxy a -> Value -> Expectation +shouldValidateValue px val = do + let (defs, sch) = runDeclare (declareSchema px) mempty + case validateJSON defs sch val of + [] -> pure () + errors -> expectationFailure $ unlines errors + shouldNotValidate :: forall a. ToSchema a => (a -> Value) -> a -> Bool shouldNotValidate f = not . null . validateJSON defs sch . f where @@ -75,7 +85,8 @@ spec = do prop "T.Text" $ shouldValidate (Proxy :: Proxy T.Text) prop "TL.Text" $ shouldValidate (Proxy :: Proxy TL.Text) prop "[String]" $ shouldValidate (Proxy :: Proxy [String]) - -- prop "(Maybe [Int])" $ shouldValidate (Proxy :: Proxy (Maybe [Int])) + prop "(Maybe [Int])" $ shouldValidate (Proxy :: Proxy (Maybe [Int])) + prop "(Maybe (Maybe Int))" $ shouldValidate (Proxy :: Proxy (Maybe (Maybe Int))) prop "(IntMap String)" $ shouldValidate (Proxy :: Proxy (IntMap String)) prop "(Set Bool)" $ shouldValidate (Proxy :: Proxy (Set Bool)) prop "(NonEmpty Bool)" $ shouldValidate (Proxy :: Proxy (NonEmpty Bool)) @@ -92,7 +103,11 @@ spec = do prop "(Int, String, Double)" $ shouldValidate (Proxy :: Proxy (Int, String, Double)) prop "(Int, String, Double, [Int])" $ shouldValidate (Proxy :: Proxy (Int, String, Double, [Int])) prop "(Int, String, Double, [Int], Int)" $ shouldValidate (Proxy :: Proxy (Int, String, Double, [Int], Int)) - prop "Person" $ shouldValidate (Proxy :: Proxy Person) + describe "Person: record with optional field" $ do + let px = Proxy :: Proxy Person + it "optional field is Just" $ shouldValidateValue px personJustEmailField + it "optional field is Null" $ shouldValidateValue px personNullEmailField + it "optional field is omitted" $ shouldValidateValue px personOmittedEmailField prop "Color" $ shouldValidate (Proxy :: Proxy Color) prop "Paint" $ shouldValidate (Proxy :: Proxy Paint) prop "MyRoseTree" $ shouldValidate (Proxy :: Proxy MyRoseTree) @@ -128,6 +143,32 @@ instance ToSchema Person instance Arbitrary Person where arbitrary = Person <$> arbitrary <*> arbitrary <*> arbitrary +personJustEmailField :: Value +personJustEmailField = [aesonQQ| + { + "name" : "foo", + "phone" : 1, + "email" : "foo@email.com" + } +|] + +personNullEmailField :: Value +personNullEmailField = [aesonQQ| + { + "name" : "foo", + "phone" : 1, + "email" : null + } +|] + +personOmittedEmailField :: Value +personOmittedEmailField = [aesonQQ| + { + "name" : "foo", + "phone" : 1 + } +|] + invalidPersonToJSON :: Person -> Value invalidPersonToJSON Person{..} = object [ stringToKey "personName" .= toJSON name