{-# LANGUAGE DefaultSignatures #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeOperators #-} -- | -- Module: Data.JSON.ToJSONSchema -- Copyright: (c) DPella AB 2025 -- License: LicenseRef-AllRightsReserved -- Maintainer: , -- -- Core machinery for deriving JSON Schema definitions from Haskell types. -- -- This module provides a type class and generic implementation for -- automatically deriving JSON Schema descriptions from Haskell data types. -- The generated schemas follow the JSON Schema 2020-12 specification. -- -- = Usage -- -- Define instances using the default generic implementation: -- -- @ -- data Person = Person -- { name :: Text -- , age :: Int -- } deriving (Generic) -- -- instance ToJSONSchema Person -- @ -- -- Or provide custom instances for more control: -- -- @ -- instance ToJSONSchema UUID where -- toJSONSchema _ = object -- [ "type" .= ("string" :: Text) -- , "minLength" .= 36 -- , "maxLength" .= 36 -- ] -- @ module Data.JSON.ToJSONSchema ( ToJSONSchema (..), Proxy (..), ) where import Data.Aeson import Data.Aeson.Key qualified as K import Data.Aeson.KeyMap qualified as KM import Data.Maybe (isJust) import Data.Proxy import Data.String (IsString (..)) import Data.Text (Text, pack) import Data.Typeable import Data.Vector qualified as V import GHC.Generics import GHC.TypeLits -- | Type class for converting Haskell types to JSON Schema. -- -- The class provides a default implementation using GHC generics, -- which works for most algebraic data types. Custom instances can -- be defined for types requiring special schema representations. class ToJSONSchema a where -- | Generate a JSON Schema for the given type. -- -- The Proxy argument carries the type information without -- requiring an actual value of that type. -- -- >>> toJSONSchema (Proxy :: Proxy Bool) -- {"type": "boolean"} toJSONSchema :: Proxy a -> Value default toJSONSchema :: ( Generic a , GToJSONSchema (Rep a) , Typeable a ) => Proxy a -> Value -- We start with no root name; the D1 instance will set the root name -- and wrap the result with $defs and a top-level $ref. This allows -- recursive types to refer to themselves using $ref without infinite recursion. toJSONSchema _ = gToJSONSchema @(Rep a) False Nothing (Proxy :: Proxy (Rep a a)) -- | String instance with overlapping to handle String as a special case, and not as [Char] instance {-# OVERLAPPING #-} ToJSONSchema String where toJSONSchema _ = object ["type" .= ("string" :: Text)] -- | Text instance. instance ToJSONSchema Text where toJSONSchema _ = object ["type" .= ("string" :: Text)] -- | Boolean schema instance. instance ToJSONSchema Bool where toJSONSchema _ = object ["type" .= ("boolean" :: Text)] -- | Machine integer schema instance. instance ToJSONSchema Int where toJSONSchema _ = object ["type" .= ("integer" :: Text)] -- | Arbitrary precision integer schema instance. instance ToJSONSchema Integer where toJSONSchema _ = object ["type" .= ("integer" :: Text)] -- | Single precision floating point schema instance. instance ToJSONSchema Float where toJSONSchema _ = object ["type" .= ("number" :: Text)] -- | Double precision floating point schema instance. instance ToJSONSchema Double where toJSONSchema _ = object ["type" .= ("number" :: Text)] -- | List schema instance for homogeneous arrays. instance (ToJSONSchema a) => ToJSONSchema [a] where toJSONSchema _ = object [ "type" .= ("array" :: Text) , "items" .= toJSONSchema (Proxy :: Proxy a) ] -- | Either schema instance for tagged unions. -- -- Encodes as Aeson's default representation with Left/Right tags: -- @ -- Left x -> {\"Left\": x} -- Right y -> {\"Right\": y} -- @ instance (ToJSONSchema a, ToJSONSchema b) => ToJSONSchema (Either a b) where toJSONSchema _ = object [ "anyOf" .= [ object [ "type" .= ("object" :: Text) , "properties" .= object [ "Left" .= toJSONSchema (Proxy :: Proxy a) ] , "required" .= ([ "Left" ] :: [Text]) , "additionalProperties" .= False ] , object [ "type" .= ("object" :: Text) , "properties" .= object [ "Right" .= toJSONSchema (Proxy :: Proxy b) ] , "required" .= ([ "Right" ] :: [Text]) , "additionalProperties" .= False ] ] ] -- | Maybe schema instance allowing null values. -- -- A Maybe value can be either the wrapped type or null: -- @ -- Just x -> x -- Nothing -> null -- @ instance (ToJSONSchema a) => ToJSONSchema (Maybe a) where toJSONSchema _ = object [ "anyOf" .= [ toJSONSchema (Proxy :: Proxy a) , object ["type" .= ("null" :: Text)] ] ] -- | Generic type class for deriving JSON schemas. -- -- This class handles the generic representation of data types -- and converts them to appropriate JSON Schema structures. -- -- The Bool parameter indicates whether we're inside a sum type -- that needs tagging for proper deserialization. class GToJSONSchema f where -- | Generate schema from generic representation. -- -- The Bool parameter controls tagged union representation: -- * True: Add "tag" field for sum type constructors -- * False: No tagging needed -- The Maybe Text carries the root datatype name, if any. When present, -- occurrences of the same datatype in fields will be rendered as -- {"$ref": "#/$defs/"} to avoid infinite recursion. gToJSONSchema :: (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (f a) -> Value -- | Instance for empty types (no constructors). -- -- Empty types are represented as JSON null since they -- can never have a value. instance GToJSONSchema V1 where gToJSONSchema _ _ _ = Null -- | Instance for unit types (constructors with no fields). -- -- Unit constructors are represented as null when untagged, -- or as objects with just a tag field when tagged. instance GToJSONSchema U1 where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (U1 a) -> Value gToJSONSchema _ _ _ = Null -- | Instance for sum types (multiple constructors). -- -- Sum types are encoded using JSON Schema's "anyOf" keyword, -- allowing the value to match any of the constructor schemas. -- -- Example: -- @ -- data Color = Red | Green | Blue -- -- Generates: {"anyOf": [{...Red schema}, {...Green schema}, {...Blue schema}]} -- @ instance (GToJSONSchema f1, GToJSONSchema f2) => GToJSONSchema (f1 :+: f2) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:+:) f1 f2 a) -> Value gToJSONSchema _ root_name _ = let v1 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f1 a)) v2 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f2 a)) in case (v1, v2) of (Object km1, Object km2) | Just (Array vec1) <- km1 KM.!? "anyOf" , Just (Array vec2) <- km2 KM.!? "anyOf" -> object ["anyOf" .= Array (vec1 <> vec2)] (Object km1, Object km2) | Just (Array vec) <- km1 KM.!? "anyOf" , Nothing <- km2 KM.!? "anyOf" -> object ["anyOf" .= Array (vec `V.snoc` v2)] (Object _, Object km2) | Just (Array vec) <- km2 KM.!? "anyOf" -> object ["anyOf" .= Array (v1 `V.cons` vec)] (_, _) -> object ["anyOf" .= [v1, v2]] -- | Instance for product types (multiple fields in a constructor). -- -- Non-record products are encoded as fixed-length arrays where -- each position has a specific type. The "items": false ensures -- no additional array elements are allowed. -- -- Example: -- @ -- data Point = Point Double Double -- -- Generates: {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}], "items": false} -- @ instance (GToJSONSchema f1, GToJSONSchema f2) => GToJSONSchema (f1 :*: f2) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:*:) f1 f2 a) -> Value gToJSONSchema _ root_name _ = object [ "type" .= ("array" :: Text) , "prefixItems" .= [ gToJSONSchema False root_name (Proxy :: Proxy (f1 a)) , gToJSONSchema False root_name (Proxy :: Proxy (f2 a)) ] , "items" .= False ] -- | Helper to flatten nested array structures in schemas. -- -- When building schemas for nested product types, we may get -- structures like prefixItems: [a, {prefixItems: [b, c]}]. -- This function flattens them to prefixItems: [a, b, c] for -- consistency with how Aeson represents such types. flattenKeys :: Key -> Value -> Value flattenKeys key (Object km) | Just (Array vec) <- km KM.!? key , length vec == 2 , vf <- V.head vec , Object vlkm <- flattenKeys key (V.last vec) , Just (Array vec') <- vlkm KM.!? key = Object ( KM.singleton key (Array (V.cons vf vec')) `KM.union` km ) flattenKeys _ o = o -- | Instance for datatype metadata. -- If this is the root, we output a $defs section with the type name and schema, -- and refer to that in $ref. instance (KnownSymbol dtn, GToJSONSchema f) => GToJSONSchema (D1 (MetaData dtn m p nt) f) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (D1 (MetaData dtn m p nt) f a) -> Value gToJSONSchema _ root_name _ = let dt_name = symbolVal (Proxy :: Proxy dtn) this_name = pack dt_name body = gToJSONSchema False (Just this_name) (Proxy :: Proxy (f a)) in case root_name of -- Top-level: wrap with $defs and $ref to support recursion Nothing -> object [ "$defs" .= object [fromString dt_name .= body] , "$ref" .= ("#/$defs/" <> this_name) ] -- Nested: just return the documented body Just _ -> body -- | Instance for type constants (actual field types). -- -- This delegates to the ToJSONSchema instance of the field type, -- allowing custom schemas for specific types. instance (ToJSONSchema c, Typeable c) => GToJSONSchema (K1 i c) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (K1 i c a) -> Value gToJSONSchema _ root_name _ = case root_name of -- If we know the root type name and the field type equals the root -- type, emit a $ref to the root definition to avoid infinite recursion. Just nm -> if typeRep (Proxy :: Proxy a) == typeRep (Proxy :: Proxy c) then object ["$ref" .= ("#/$defs/" <> nm)] else toJSONSchema (Proxy :: Proxy c) Nothing -> toJSONSchema (Proxy :: Proxy c) -- | Instance for record constructors. -- -- Record types are encoded as objects with named properties. -- When in a tagged sum type, adds a "tag" field with the -- constructor name for discrimination. -- -- Example: -- @ -- data Person = Person {name :: Text, age :: Int} -- -- Generates: { -- -- "type": "object", -- -- "properties": { -- -- "name": {"type": "string"}, -- -- "age": {"type": "integer"} -- -- }, -- -- "additionalProperties": false -- -- } -- @ instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity True) f) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity True) f a) -> Value gToJSONSchema tagged root_name _ = let props_val = extractProperties $ gToJSONSchema tagged root_name (Proxy :: Proxy (f a)) props_keys = case props_val of Object km -> fmap K.toText (KM.keys km) _ -> [] requiredFields = (if tagged then ("tag" :) else id) props_keys requiredPairs = if null requiredFields then [] else ["required" .= requiredFields] in object ( [ "type" .= ("object" :: Text) , "properties" .= if tagged then addTag props_val else props_val , "additionalProperties" .= False ] ++ requiredPairs ) where tag = object ["const" .= cn] addTag (Object km) = Object $ KM.singleton (fromString "tag") tag `KM.union` km addTag o = o xP (Object p) | Just (Object r) <- p KM.!? "properties" = Just r xP _ = Nothing cn = symbolVal (Proxy :: Proxy name) extractProperties :: Value -> Value extractProperties o@(Object _) | Object km <- flattenKeys "prefixItems" o , Just (Array vec) <- km KM.!? "prefixItems" , V.all (isJust . xP) vec = Object $ V.foldl KM.union KM.empty $ V.mapMaybe xP vec extractProperties (Object km) | Just p <- km KM.!? "properties" = p extractProperties o = o -- | Instance for non-record constructors. -- -- Non-record constructors with multiple fields are encoded as arrays. -- When in a tagged sum type, wraps in an object with "tag" and "contents". -- -- Examples: -- @ -- data Point = Point Double Double -- -- Untagged: {"type": "array", "prefixItems": [...], "items": false} -- -- data Shape = Circle Double | Rectangle Double Double -- -- Tagged: { -- -- "type": "object", -- -- "properties": { -- -- "tag": {"const": "Circle"}, -- -- "contents": {"type": "number"} -- -- } -- -- } -- @ instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity False) f) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity False) f a) -> Value gToJSONSchema tagged root_name _ = let c_val = flattenKeys "prefixItems" $ gToJSONSchema False root_name (Proxy :: Proxy (f a)) c_name = symbolVal (Proxy :: Proxy name) tag = object ["const" .= c_name] in case c_val of o@(Object km) -> let obj = case km KM.!? "prefixItems" of Just pfi@(Array _) -> object ["type" .= ("array" :: Text), "prefixItems" .= pfi, "items" .= False] _ -> o basePairs = [ "type" .= ("object" :: Text) , "properties" .= object ["tag" .= tag, "contents" .= obj] , "additionalProperties" .= False ] requiredPairs = ["required" .= (["tag", "contents"] :: [Text])] in if tagged then object (basePairs ++ requiredPairs) else obj Null -> object [ "type" .= ("object" :: Text) , "properties" .= object ["tag" .= tag] , "additionalProperties" .= False , "required" .= ([ "tag" ] :: [Text]) ] x -> x -- | Instance for unnamed fields (positional constructor arguments). -- -- Simply delegates to the field type's schema without wrapping. instance (GToJSONSchema f) => GToJSONSchema (S1 (MetaSel Nothing su ss ds) f) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel Nothing su ss ds) f a) -> Value gToJSONSchema _ root_name _ = gToJSONSchema False root_name (Proxy :: Proxy (f a)) -- | Instance for named record fields. -- -- Creates an object schema with a single property named after -- the field. These are combined by the record constructor instance. -- -- Example: -- @ -- data Person = Person { name :: Text } -- -- For the 'name' field generates: -- -- { -- -- "type": "object", -- -- "properties": { -- -- "name": {"type": "string"} -- -- } -- -- } -- @ instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (S1 (MetaSel (Just name) su ss ds) f) where gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel (Just name) su ss ds) f a) -> Value gToJSONSchema _ root_name _ = object [ "type" .= ("object" :: Text) , "properties" .= object [ fromString (symbolVal (Proxy :: Proxy name)) .= gToJSONSchema False root_name (Proxy :: Proxy (f a)) ] ]