acolyte-core-0.1.0.0: src/Acolyte/Core/Effect.hs
-- | Type-level middleware effects.
--
-- Effects are type-level tags that declare what middleware an endpoint
-- requires. The effect system has two parts:
--
-- 1. 'Requires' wraps an endpoint in the API type to declare a requirement.
-- 2. 'AllEffectsProvided' checks at serve-time that every required effect
-- has been provided.
--
-- Effects are user-extensible: any type of kind 'Type' can be an effect.
-- Built-in effects ('Auth', 'Cors', etc.) are provided for convenience.
--
-- @
-- type MyAPI =
-- '[ Requires Auth (Get '[ Lit "users" ] (Json [User]))
-- , Requires Cors (Get '[ Lit "public" ] (Json Data))
-- , Get '[ Lit "health" ] String -- no requirements
-- ]
-- @
module Acolyte.Core.Effect
( -- * Built-in effect tags
Auth
, Cors
, RateLimit
, Tracing
-- * Declaring requirements
, Requires
-- * Checking requirements
, HasEffect
, RequiredEffects
, AllEffectsProvided
, Assert
) where
import Data.Kind (Type, Constraint)
import GHC.TypeLits (TypeError, ErrorMessage (..))
import Acolyte.Core.API (type (++))
-- ===================================================================
-- Built-in effect tags (user-extensible: any Type can be an effect)
-- ===================================================================
-- | Authentication middleware required.
data Auth
-- | CORS middleware required.
data Cors
-- | Rate limiting middleware required.
data RateLimit
-- | Tracing/observability middleware required.
data Tracing
-- ===================================================================
-- Requires: declare that an endpoint needs a middleware effect
-- ===================================================================
-- | Declare that an endpoint requires effect @e@.
--
-- @Requires@ is a type-level wrapper — it marks the endpoint but does
-- not change its runtime behavior. At serve-time, 'AllEffectsProvided'
-- checks that every @Requires e _@ in the API has its @e@ in the
-- provided list. If not, compilation fails.
--
-- Multiple effects can be nested:
--
-- @
-- Requires Auth (Requires RateLimit (Get path resp))
-- @
type Requires :: Type -> Type -> Type
data Requires (e :: Type) (endpoint :: Type)
-- ===================================================================
-- Type-level membership check
-- ===================================================================
-- | Check whether effect @e@ is in the provided list @es@.
--
-- Closed type family — O(n) in the length of the list, evaluated by
-- simple top-to-bottom matching with no backtracking.
type HasEffect :: Type -> [Type] -> Bool
type family HasEffect (e :: Type) (es :: [Type]) :: Bool where
HasEffect _ '[] = 'False
HasEffect e (e ': _) = 'True
HasEffect e (_ ': es) = HasEffect e es
-- ===================================================================
-- Collect all required effects from an API
-- ===================================================================
-- | Collect all effects required by endpoints in an API.
--
-- Walks the API list and extracts the @e@ from every @Requires e _@.
-- Nested @Requires@ are flattened. Non-@Requires@ endpoints contribute
-- nothing.
type RequiredEffects :: [Type] -> [Type]
type family RequiredEffects (api :: [Type]) :: [Type] where
RequiredEffects '[] = '[]
RequiredEffects (Requires e inner ': rest) =
e ': RequiredEffects ('[inner] ++ rest)
RequiredEffects (_ ': rest) = RequiredEffects rest
-- ===================================================================
-- AllEffectsProvided: the compile-time completeness check
-- ===================================================================
-- | Assert with a custom error message.
type Assert :: Bool -> ErrorMessage -> Constraint
type family Assert (b :: Bool) (msg :: ErrorMessage) :: Constraint where
Assert 'True _ = ()
Assert 'False msg = TypeError msg
-- | Verify that every effect required by the API is in the provided list.
--
-- Two-step design: 'RequiredEffects' collects all effect tags from
-- the API, then 'AllIn' checks each one against the provided list.
-- This separates collection from verification.
--
-- @
-- -- Compiles: Auth and Cors are both provided.
-- example :: AllEffectsProvided MyAPI '[Auth, Cors] => ()
--
-- -- Fails: Auth is missing.
-- bad :: AllEffectsProvided MyAPI '[Cors] => ()
-- @
type AllEffectsProvided (api :: [Type]) (provided :: [Type]) =
AllIn (RequiredEffects api) provided
-- | Check that every effect in @required@ is present in @provided@.
-- Produces a clear compile error naming the first missing effect.
type AllIn :: [Type] -> [Type] -> Constraint
type family AllIn (required :: [Type]) (provided :: [Type]) :: Constraint where
AllIn '[] _ = ()
AllIn (e ': es) provided =
( Assert (HasEffect e provided)
( 'Text "Missing middleware effect: " ':<>: 'ShowType e
':$$: 'Text "This effect is required by an endpoint but was not provided."
':$$: 'Text "Add .provide @" ':<>: 'ShowType e
':<>: 'Text " to the server builder."
)
, AllIn es provided
)