shikumi-0.3.0.0: src/Shikumi/Schema.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE UndecidableInstances #-}
-- | The schema engine: derive a provider JSON Schema from a Haskell record
-- ('ToSchema' / 'deriveSchema'), and totally decode a provider JSON reply back
-- into a record ('FromModel' / 'parseOutput'), where every failure becomes a
-- precise, located 'Shikumi.Error.ShikumiError'.
--
-- Both directions are hand-rolled on 'GHC.Generics' (the schema we emit is the
-- small subset OpenAI/Anthropic structured-output endpoints accept). Per-field
-- descriptions flow from the 'Field' wrapper's type-level 'Symbol'.
module Shikumi.Schema
( -- * Forward: record -> JSON Schema
ToSchema (..),
deriveSchema,
-- * Inverse: JSON -> record (total)
FromModel (..),
fromModel,
fromModelChecked,
parseOutput,
-- * Validation hook
Validatable (..),
-- * Declarative field constraints (EP-26)
ReflectConstraints (..),
-- * Field-metadata traversal (shared with "Shikumi.Signature")
GFieldMetas (..),
fieldMetasOf,
-- * Re-exports
module Shikumi.Schema.Types,
)
where
import Control.Applicative ((<|>))
import Data.Aeson (Object, Value (..), eitherDecodeStrict)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Bifunctor (first)
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Scientific qualified as Sci
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Generics
import GHC.TypeLits (KnownNat, KnownSymbol, Symbol, natVal, symbolVal)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Schema.Types
import Text.Read (readMaybe)
-- ---------------------------------------------------------------------------
-- Forward direction: ToSchema
-- ---------------------------------------------------------------------------
-- | Types that can describe themselves as a provider JSON Schema. Records get a
-- generic default; leaves and containers have explicit instances.
class ToSchema a where
toSchema :: Proxy a -> Value
default toSchema :: (GToSchema (Rep a)) => Proxy a -> Value
toSchema _ = gToSchema (Proxy @(Rep a))
-- | The JSON Schema for @a@.
deriveSchema :: forall a. (ToSchema a) => Value
deriveSchema = toSchema (Proxy @a)
instance ToSchema Text where toSchema _ = stringSchema
instance ToSchema Int where toSchema _ = integerSchema
instance ToSchema Integer where toSchema _ = integerSchema
instance ToSchema Double where toSchema _ = numberSchema
instance ToSchema Bool where toSchema _ = boolSchema
instance (ToSchema a) => ToSchema [a] where toSchema _ = arraySchema (toSchema (Proxy @a))
instance (ToSchema a) => ToSchema (Vector a) where toSchema _ = arraySchema (toSchema (Proxy @a))
instance (ToSchema a) => ToSchema (Maybe a) where toSchema _ = nullableSchema (toSchema (Proxy @a))
-- | The description is attached at the record-field layer ('FieldSchema'), not
-- here, so this instance stays composable (e.g. a nested @Field@).
instance (ToSchema a) => ToSchema (Field d a) where toSchema _ = toSchema (Proxy @a)
-- | Generic schema walk. A single record constructor becomes an object schema; a
-- sum of nullary constructors becomes a string @enum@.
class GToSchema (f :: Type -> Type) where
gToSchema :: Proxy f -> Value
instance (GToSchema cs) => GToSchema (D1 d cs) where
gToSchema _ = gToSchema (Proxy @cs)
instance (GRecordFields fs) => GToSchema (C1 ('MetaCons n fx 'True) fs) where
gToSchema _ = uncurry objectSchema (gRecordFields (Proxy @fs))
instance (GEnumNames (l :+: r)) => GToSchema (l :+: r) where
gToSchema _ = enumSchema (gEnumNames (Proxy @(l :+: r)))
-- | Collect @(name, schema)@ properties and the required-name list for a record.
class GRecordFields (f :: Type -> Type) where
gRecordFields :: Proxy f -> ([(Text, Value)], [Text])
instance (GRecordFields a, GRecordFields b) => GRecordFields (a :*: b) where
gRecordFields _ = gRecordFields (Proxy @a) <> gRecordFields (Proxy @b)
instance (Selector s, FieldSchema t) => GRecordFields (S1 s (K1 R t)) where
gRecordFields _ =
let nm = T.pack (selName (undefined :: S1 s (K1 R t) ()))
(sch, req) = fieldSchema (Proxy @t)
in ([(nm, sch)], [nm | req])
-- | Per-field schema and whether the field is required. The general
-- (overlappable) case is "required, no description"; 'Maybe' is
-- required-but-nullable (see below); 'Field' attaches the description and
-- preserves the inner required-ness.
--
-- A 'Maybe' field is /required/ in the JSON Schema and expresses its optionality
-- as nullability (a nullable schema). OpenAI strict mode (@strict: true@, which
-- the router always requests) requires every property to appear in @required@;
-- omitting a property is rejected. The decode side is unaffected —
-- @FromField (Maybe a)@ accepts a missing key, an explicit @null@, or a value —
-- so a fallback-model reply that omits the field still decodes.
class FieldSchema t where
fieldSchema :: Proxy t -> (Value, Bool)
instance {-# OVERLAPPABLE #-} (ToSchema a) => FieldSchema a where
fieldSchema _ = (toSchema (Proxy @a), True)
instance (ToSchema a) => FieldSchema (Maybe a) where
fieldSchema _ = (nullableSchema (toSchema (Proxy @a)), True)
instance (KnownSymbol d, FieldSchema a) => FieldSchema (Field d a) where
fieldSchema _ =
let (s, req) = fieldSchema (Proxy @a)
in (withDescription (T.pack (symbolVal (Proxy @d))) s, req)
-- | A 'Constrained' field emits its constraints' JSON-Schema keywords onto the
-- inner type's schema, preserving the inner required-ness (EP-26).
instance forall cs a. (ReflectConstraints cs a, FieldSchema a) => FieldSchema (Constrained cs a) where
fieldSchema _ =
let (s, req) = fieldSchema (Proxy @a)
in (constraintSchema @cs @a Proxy s, req)
-- | Constructor names of a sum of nullary constructors.
class GEnumNames (f :: Type -> Type) where
gEnumNames :: Proxy f -> [Text]
instance (GEnumNames a, GEnumNames b) => GEnumNames (a :+: b) where
gEnumNames _ = gEnumNames (Proxy @a) ++ gEnumNames (Proxy @b)
instance (Constructor c) => GEnumNames (C1 c U1) where
gEnumNames _ = [T.pack (conName (undefined :: C1 c U1 ()))]
-- ---------------------------------------------------------------------------
-- Inverse direction: FromModel (total decode)
-- ---------------------------------------------------------------------------
-- | Types decodable from provider JSON, totally. The path-carrying 'fromModelP'
-- threads a breadcrumb so every error is located; 'fromModel' starts at the root.
class FromModel a where
fromModelP :: FieldPath -> Value -> Either ShikumiError a
default fromModelP :: (Generic a, GFromModel (Rep a)) => FieldPath -> Value -> Either ShikumiError a
fromModelP path v = to <$> gFromModel path v
-- | Decode from a JSON 'Value' (no validation).
fromModel :: (FromModel a) => Value -> Either ShikumiError a
fromModel = fromModelP []
-- | Decode then run the type's 'Validatable' rule, locating a failure as a
-- 'ValidationFailure'.
fromModelChecked :: (FromModel a, Validatable a) => Value -> Either ShikumiError a
fromModelChecked v = do
a <- fromModel v
first ValidationFailure (validate a)
-- | Parse a raw response body to JSON (mapping a parse failure to 'InvalidJSON'),
-- decode it, and validate.
parseOutput :: forall a. (FromModel a, Validatable a) => Text -> Either ShikumiError a
parseOutput body = case eitherDecodeStrict (encodeUtf8 body) of
Left e -> Left (InvalidJSON (T.pack e))
Right v -> fromModelChecked v
instance FromModel Text where
fromModelP path = \case String t -> Right t; v -> mismatch path "string" v
instance FromModel Int where
fromModelP path = \case
Number n -> maybe (mismatch path "integer" (Number n)) Right (Sci.toBoundedInteger n)
v -> mismatch path "integer" v
instance FromModel Integer where
fromModelP path = \case
Number n -> case Sci.floatingOrInteger n :: Either Double Integer of
Right i -> Right i
Left _ -> mismatch path "integer" (Number n)
v -> mismatch path "integer" v
instance FromModel Double where
fromModelP path = \case Number n -> Right (Sci.toRealFloat n); v -> mismatch path "number" v
instance FromModel Bool where
fromModelP path = \case Bool b -> Right b; v -> mismatch path "boolean" v
instance (FromModel a) => FromModel [a] where
fromModelP path = \case
Array arr -> traverse (\(i, x) -> fromModelP (pushIndex i path) x) (zip [0 ..] (V.toList arr))
v -> mismatch path "array" v
instance (FromModel a) => FromModel (Vector a) where
fromModelP path v = V.fromList <$> (fromModelP path v :: Either ShikumiError [a])
instance (FromModel a) => FromModel (Maybe a) where
fromModelP _ Null = Right Nothing
fromModelP path v = Just <$> fromModelP path v
instance (FromModel a) => FromModel (Field d a) where
fromModelP path v = Field <$> fromModelP path v
-- | A 'Constrained' field decodes its inner value, then enforces the constraints
-- (EP-26). A violation becomes a 'ValidationFailure' located at the field, so the
-- check composes with any record-level 'Validatable' rule with no change to
-- 'fromModelChecked'. (@FromField (Constrained cs a)@ is covered by the
-- overlappable @FromField@ instance via this @FromModel@ instance.)
instance (FromModel a, ReflectConstraints cs a) => FromModel (Constrained cs a) where
fromModelP path v = do
a <- fromModelP path v
case checkConstraints (Proxy @cs) a of
Right ok -> Right (Constrained ok)
Left msg -> Left (ValidationFailure (renderPath path <> ": " <> msg))
-- | Generic decode walk, mirroring 'GToSchema'.
class GFromModel (f :: Type -> Type) where
gFromModel :: FieldPath -> Value -> Either ShikumiError (f p)
instance (GFromModel cs) => GFromModel (D1 d cs) where
gFromModel path v = M1 <$> gFromModel path v
instance (GFromRecord fs) => GFromModel (C1 ('MetaCons n fx 'True) fs) where
gFromModel path = \case
Object o -> M1 <$> gFromRecord path o
v -> mismatch path "object" v
instance (GEnumParse (l :+: r)) => GFromModel (l :+: r) where
gFromModel path = \case
String t -> case gEnumParse t of
Just x -> Right x
Nothing ->
Left
( SchemaMismatch
(renderPath path <> ": expected one of [" <> T.intercalate ", " (gEnumNamesP (Proxy @(l :+: r))) <> "]")
)
v -> mismatch path "string" v
-- | Decode a record object field by field, threading the path.
class GFromRecord (f :: Type -> Type) where
gFromRecord :: FieldPath -> Object -> Either ShikumiError (f p)
instance (GFromRecord a, GFromRecord b) => GFromRecord (a :*: b) where
gFromRecord path o = (:*:) <$> gFromRecord path o <*> gFromRecord path o
instance (Selector s, FromField t) => GFromRecord (S1 s (K1 R t)) where
gFromRecord path o =
let nm = T.pack (selName (undefined :: S1 s (K1 R t) ()))
in M1 . K1 <$> fromField (pushField nm path) nm o
-- | Look a field up in the JSON object. The general (overlappable) case requires
-- the field; 'Maybe' and a missing key succeed as 'Nothing'; 'Field' delegates.
class FromField t where
fromField :: FieldPath -> Text -> Object -> Either ShikumiError t
instance {-# OVERLAPPABLE #-} (FromModel t) => FromField t where
fromField path nm o = case KM.lookup (Key.fromText nm) o of
Nothing -> Left (MissingField (renderPath path))
Just v -> fromModelP path v
instance (FromModel a) => FromField (Maybe a) where
fromField path nm o = case KM.lookup (Key.fromText nm) o of
Nothing -> Right Nothing
Just Null -> Right Nothing
Just v -> Just <$> fromModelP path v
instance (FromField a) => FromField (Field d a) where
fromField path nm o = Field <$> fromField path nm o
-- | Parse a string against the constructor names of an enum-like sum.
class GEnumParse (f :: Type -> Type) where
gEnumParse :: Text -> Maybe (f p)
gEnumNamesP :: Proxy f -> [Text]
instance (GEnumParse a, GEnumParse b) => GEnumParse (a :+: b) where
gEnumParse t = (L1 <$> gEnumParse t) <|> (R1 <$> gEnumParse t)
gEnumNamesP _ = gEnumNamesP (Proxy @a) ++ gEnumNamesP (Proxy @b)
instance (Constructor c) => GEnumParse (C1 c U1) where
gEnumParse t
| t == nm = Just (M1 U1)
| otherwise = Nothing
where
nm = T.pack (conName (undefined :: C1 c U1 ()))
gEnumNamesP _ = [T.pack (conName (undefined :: C1 c U1 ()))]
-- ---------------------------------------------------------------------------
-- Validation hook
-- ---------------------------------------------------------------------------
-- | A domain rule applied after a successful decode, enforced by the decode path
-- in every program runner (@runProgram@, @runProgramConc@, @streamProgram@, and
-- the derived modules). Override per output type to enforce rules (e.g.
-- "bullets has 3-5 elements"); a violation surfaces as a
-- 'Shikumi.Error.ValidationFailure'.
--
-- Instances are opt-in: there is no catch-all. Every type used as a @Predict@
-- output (or a tool input) must declare an instance. A type with no rules
-- declares an empty instance — @instance Validatable Foo@ (no body; the default
-- @validate = Right@ applies) — or adds @Validatable@ to a @deriving anyclass@
-- list (@DeriveAnyClass@ is a default extension in every package). Making the
-- no-rules case explicit is deliberate: it prevents a forgotten constraint from
-- silently regressing to "always valid".
class Validatable a where
validate :: a -> Either Text a
validate = Right
-- ---------------------------------------------------------------------------
-- Declarative field constraints (EP-26)
-- ---------------------------------------------------------------------------
-- | Reflect a type-level constraint list (the @cs@ of a
-- 'Shikumi.Schema.Types.Constrained') to (a) a schema-decorating function that
-- inserts the matching JSON-Schema keywords and (b) a runtime validator over the
-- decoded leaf value. The vocabulary is a small, closed set
-- ('Shikumi.Schema.Types.Constraint'); instances cover the five constructors over
-- 'Text' and the numeric leaves.
class ReflectConstraints (cs :: [Constraint]) a where
-- | Compose all keyword inserts onto the inner type's schema.
constraintSchema :: Proxy cs -> Value -> Value
-- | Check the decoded value against every constraint; 'Left' names the first
-- rule that failed.
checkConstraints :: Proxy cs -> a -> Either Text a
instance ReflectConstraints '[] a where
constraintSchema _ = id
checkConstraints _ = Right
instance (KnownNat n, ReflectConstraints cs Text) => ReflectConstraints ('MinLen n ': cs) Text where
constraintSchema _ = constraintSchema @cs @Text Proxy . withMinLength (fromIntegral (natVal (Proxy @n)))
checkConstraints _ t
| T.length t < fromIntegral (natVal (Proxy @n)) = Left ("minLength " <> tshow (natVal (Proxy @n)) <> " violated")
| otherwise = checkConstraints (Proxy @cs) t
instance (KnownNat n, ReflectConstraints cs Text) => ReflectConstraints ('MaxLen n ': cs) Text where
constraintSchema _ = constraintSchema @cs @Text Proxy . withMaxLength (fromIntegral (natVal (Proxy @n)))
checkConstraints _ t
| T.length t > fromIntegral (natVal (Proxy @n)) = Left ("maxLength " <> tshow (natVal (Proxy @n)) <> " violated")
| otherwise = checkConstraints (Proxy @cs) t
instance forall s cs a. (KnownSymbol s, ToScientificLeaf a, ReflectConstraints cs a) => ReflectConstraints ('MinVal s ': cs) a where
constraintSchema _ = constraintSchema @cs @a Proxy . maybe id withMinimum (parseBound (Proxy @s))
checkConstraints _ x = case parseBound (Proxy @s) of
Nothing -> Left ("invalid numeric bound symbol: " <> T.pack (symbolVal (Proxy @s)))
Just b
| toScientificLeaf x < b -> Left ("minimum " <> T.pack (symbolVal (Proxy @s)) <> " violated")
| otherwise -> checkConstraints (Proxy @cs) x
instance forall s cs a. (KnownSymbol s, ToScientificLeaf a, ReflectConstraints cs a) => ReflectConstraints ('MaxVal s ': cs) a where
constraintSchema _ = constraintSchema @cs @a Proxy . maybe id withMaximum (parseBound (Proxy @s))
checkConstraints _ x = case parseBound (Proxy @s) of
Nothing -> Left ("invalid numeric bound symbol: " <> T.pack (symbolVal (Proxy @s)))
Just b
| toScientificLeaf x > b -> Left ("maximum " <> T.pack (symbolVal (Proxy @s)) <> " violated")
| otherwise -> checkConstraints (Proxy @cs) x
instance (KnownSymbols ss, ReflectConstraints cs Text) => ReflectConstraints ('EnumOneOf ss ': cs) Text where
constraintSchema _ = constraintSchema @cs @Text Proxy . withEnum (symbolTexts (Proxy @ss))
checkConstraints _ t
| t `elem` symbolTexts (Proxy @ss) = checkConstraints (Proxy @cs) t
| otherwise = Left ("enum membership violated (allowed: " <> T.intercalate ", " (symbolTexts (Proxy @ss)) <> ")")
-- | A leaf type comparable against a 'Sci.Scientific' bound. Closed to the numeric
-- leaves shikumi decodes.
class ToScientificLeaf a where
toScientificLeaf :: a -> Sci.Scientific
instance ToScientificLeaf Int where toScientificLeaf = fromIntegral
instance ToScientificLeaf Integer where toScientificLeaf = fromIntegral
instance ToScientificLeaf Double where toScientificLeaf = Sci.fromFloatDigits
-- | Reflect a type-level list of 'Symbol's to their 'Text's (for @EnumOneOf@).
class KnownSymbols (ss :: [Symbol]) where
symbolTexts :: Proxy ss -> [Text]
instance KnownSymbols '[] where symbolTexts _ = []
instance (KnownSymbol s, KnownSymbols ss) => KnownSymbols (s ': ss) where
symbolTexts _ = T.pack (symbolVal (Proxy @s)) : symbolTexts (Proxy @ss)
-- | Parse a numeric-bound 'Symbol' (e.g. @"100"@, @"-3.5"@) to a 'Sci.Scientific',
-- or 'Nothing' when the symbol is not a number. Total — a malformed bound (e.g.
-- @MinVal "abc"@) no longer crashes the process at schema-derivation time; instead
-- the schema keyword is silently omitted and every decode of the field fails with a
-- located 'ValidationFailure' naming the bad symbol (see the @MinVal@/@MaxVal@
-- 'ReflectConstraints' instances).
parseBound :: forall s. (KnownSymbol s) => Proxy s -> Maybe Sci.Scientific
parseBound _ = readMaybe (symbolVal (Proxy @s))
tshow :: (Show a) => a -> Text
tshow = T.pack . show
-- ---------------------------------------------------------------------------
-- Shared field-metadata traversal (also used by Shikumi.Signature)
-- ---------------------------------------------------------------------------
-- | Collect 'FieldMeta' (name + optional description) for every record field of a
-- type, recovering descriptions from 'Field' wrappers.
fieldMetasOf :: forall a. (GFieldMetas (Rep a)) => [FieldMeta]
fieldMetasOf = gFieldMetas (Proxy @(Rep a))
-- | Generic field-metadata walk.
class GFieldMetas (f :: Type -> Type) where
gFieldMetas :: Proxy f -> [FieldMeta]
instance (GFieldMetas cs) => GFieldMetas (D1 d cs) where
gFieldMetas _ = gFieldMetas (Proxy @cs)
instance (GFieldMetas fs) => GFieldMetas (C1 c fs) where
gFieldMetas _ = gFieldMetas (Proxy @fs)
instance (GFieldMetas a, GFieldMetas b) => GFieldMetas (a :*: b) where
gFieldMetas _ = gFieldMetas (Proxy @a) ++ gFieldMetas (Proxy @b)
instance GFieldMetas U1 where
gFieldMetas _ = []
instance (Selector s, FieldDoc t) => GFieldMetas (S1 s (K1 R t)) where
gFieldMetas _ =
[ FieldMeta
{ fieldName = T.pack (selName (undefined :: S1 s (K1 R t) ())),
fieldDesc = fieldDoc (Proxy @t)
}
]
-- | The description of a field's type, if it is a 'Field' wrapper.
class FieldDoc t where
fieldDoc :: Proxy t -> Maybe Text
instance {-# OVERLAPPABLE #-} FieldDoc a where
fieldDoc _ = Nothing
instance (KnownSymbol d) => FieldDoc (Field d a) where
fieldDoc _ = Just (T.pack (symbolVal (Proxy @d)))
-- ---------------------------------------------------------------------------
-- Internal helpers
-- ---------------------------------------------------------------------------
mismatch :: FieldPath -> Text -> Value -> Either ShikumiError a
mismatch path expected got =
Left (SchemaMismatch (renderPath path <> ": expected " <> expected <> ", got " <> jsonTypeOf got))
jsonTypeOf :: Value -> Text
jsonTypeOf = \case
String {} -> "string"
Number {} -> "number"
Bool {} -> "boolean"
Null -> "null"
Array {} -> "array"
Object {} -> "object"