acolyte-openapi-0.1.0.0: src/Acolyte/OpenApi/Schema.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- | JSON Schema derivation for OpenAPI.
--
-- 'ToSchema' produces a JSON Schema object for a type, used in
-- request body and response schemas in the OpenAPI spec.
--
-- For record types, use @deriving Generic@ and the default implementation:
--
-- @
-- data User = User { userName :: Text, userAge :: Int }
-- deriving (Generic)
--
-- instance ToSchema User
-- -- Produces: { "type": "object", "properties": { "userName": { "type": "string" }, "userAge": { "type": "integer" } } }
-- @
module Acolyte.OpenApi.Schema
( -- * Schema type
Schema (..)
, schemaToJson
-- * Schema class
, ToSchema (..)
-- * Generic derivation helper
, GToSchema (..)
, GToSchemaCon (..)
, genericToSchema
) where
import Data.Aeson (Value (..), object, (.=))
import qualified Data.Aeson.Key as Key
import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Proxy (Proxy (..))
import GHC.Generics
import GHC.TypeLits (KnownSymbol, symbolVal)
import Acolyte.Core.Endpoint (Json)
-- | A simplified JSON Schema representation.
data Schema = Schema
{ schemaType :: !Text
, schemaProperties :: ![(Text, Schema)]
, schemaItems :: !(Maybe Schema)
, schemaRef :: !(Maybe Text)
, schemaOneOf :: !(Maybe [Schema])
, schemaEnum :: !(Maybe [Text])
, schemaNullable :: !Bool
} deriving (Show, Eq)
-- | Convert a schema to an aeson Value.
schemaToJson :: Schema -> Value
schemaToJson s
| Just ref <- schemaRef s = object $ ["$ref" .= ref] ++ nullableField
| Just variants <- schemaOneOf s = object $ ["oneOf" .= map schemaToJson variants] ++ nullableField
| Just values <- schemaEnum s = object $ ["type" .= schemaType s, "enum" .= values] ++ nullableField
| schemaType s == "array" = object $
["type" .= schemaType s]
++ maybe [] (\items -> ["items" .= schemaToJson items]) (schemaItems s)
++ nullableField
| schemaType s == "object" && not (null (schemaProperties s)) = object $
[ "type" .= schemaType s
, "properties" .= object
[ Key.fromText k .= schemaToJson v | (k, v) <- schemaProperties s ]
] ++ nullableField
| otherwise = object $ ["type" .= schemaType s] ++ nullableField
where
nullableField = if schemaNullable s then ["nullable" .= True] else []
-- | Derive a JSON Schema for a type.
--
-- For record types with a 'Generic' instance, the default implementation
-- uses 'genericToSchema' to produce an object schema with properties
-- derived from field names:
--
-- @
-- data User = User { userName :: Text, userAge :: Int }
-- deriving (Generic)
--
-- instance ToSchema User
-- @
class ToSchema a where
toSchema :: Schema
default toSchema :: (Generic a, GToSchema (Rep a)) => Schema
toSchema = genericToSchema @a
-- | Derive a 'ToSchema' instance from a type's 'Generic' representation.
--
-- Produces an object schema with properties extracted from record
-- selectors. Field names become property keys, field types become
-- property schemas (via their own 'ToSchema' instances).
genericToSchema :: forall a. (Generic a, GToSchema (Rep a)) => Schema
genericToSchema = gToSchemaFull @(Rep a)
-- | Walk a Generic representation to extract (fieldName, schema) pairs.
class GToSchema (f :: Type -> Type) where
gToSchema :: [(Text, Schema)]
gToSchemaFull :: Schema
gToSchemaFull = Schema "object" (gToSchema @f) Nothing Nothing Nothing Nothing False
-- Metadata wrapper (datatype info) — delegate to inner
instance GToSchema f => GToSchema (D1 meta f) where
gToSchema = gToSchema @f
gToSchemaFull = gToSchemaFull @f
-- Constructor wrapper — delegate to inner
instance GToSchema f => GToSchema (C1 meta f) where
gToSchema = gToSchema @f
gToSchemaFull = gToSchemaFull @f
-- Product: combine left and right fields
instance (GToSchema f, GToSchema g) => GToSchema (f :*: g) where
gToSchema = gToSchema @f ++ gToSchema @g
-- Record selector with a named field
instance (KnownSymbol name, ToSchema t)
=> GToSchema (S1 ('MetaSel ('Just name) su ss ds) (Rec0 t)) where
gToSchema = [(T.pack (symbolVal (Proxy @name)), toSchema @t)]
-- No fields (unit constructor)
instance GToSchema U1 where
gToSchema = []
-- Sum types — produce oneOf schema with tagged variants
instance (GToSchemaCon (f :+: g)) => GToSchema (f :+: g) where
gToSchema = []
gToSchemaFull = Schema "object" [] Nothing Nothing (Just (gToSchemaCon @(f :+: g))) Nothing False
-- ===================================================================
-- Sum type constructor schemas
-- ===================================================================
-- | Per-constructor schema (for oneOf in sum types).
class GToSchemaCon (f :: Type -> Type) where
gToSchemaCon :: [Schema]
instance (GToSchemaCon f, GToSchemaCon g) => GToSchemaCon (f :+: g) where
gToSchemaCon = gToSchemaCon @f ++ gToSchemaCon @g
instance (KnownSymbol name, GToSchema f)
=> GToSchemaCon (C1 ('MetaCons name fix rec) f) where
gToSchemaCon =
let props = gToSchema @f
tagProp = ("tag", Schema "string" [] Nothing Nothing Nothing
(Just [T.pack (symbolVal (Proxy @name))]) False)
in [Schema "object" (tagProp : props) Nothing Nothing Nothing Nothing False]
-- ===================================================================
-- Primitive instances
-- ===================================================================
instance ToSchema Int where
toSchema = Schema "integer" [] Nothing Nothing Nothing Nothing False
instance ToSchema Integer where
toSchema = Schema "integer" [] Nothing Nothing Nothing Nothing False
instance ToSchema Double where
toSchema = Schema "number" [] Nothing Nothing Nothing Nothing False
instance ToSchema Float where
toSchema = Schema "number" [] Nothing Nothing Nothing Nothing False
instance ToSchema Bool where
toSchema = Schema "boolean" [] Nothing Nothing Nothing Nothing False
instance ToSchema Text where
toSchema = Schema "string" [] Nothing Nothing Nothing Nothing False
instance ToSchema String where
toSchema = Schema "string" [] Nothing Nothing Nothing Nothing False
instance ToSchema () where
toSchema = Schema "object" [] Nothing Nothing Nothing Nothing False
instance ToSchema a => ToSchema [a] where
toSchema = Schema "array" [] (Just (toSchema @a)) Nothing Nothing Nothing False
instance ToSchema a => ToSchema (Maybe a) where
toSchema = (toSchema @a) { schemaNullable = True }
instance (ToSchema a, ToSchema b) => ToSchema (Either a b) where
toSchema = Schema "object" [] Nothing Nothing (Just [toSchema @a, toSchema @b]) Nothing False
instance ToSchema a => ToSchema (Json a) where
toSchema = toSchema @a