keiro-core-0.9.0.0: src/Keiro/Codec/IdDomain.hs
-- | Published runtime contract for canonical prefix-bearing TypeID-v7 values.
module Keiro.Codec.IdDomain
( IdNormalization (..),
IdDomainContract (..),
IdDomainFailure (..),
enforcedIdDomainVersion,
typeIdV7Domain,
idDomainAcceptsText,
validateIdDomainText,
parseKindIdV7Text,
parseKindIdV7Value,
idDomainTextPattern,
idDomainSampleText,
)
where
import Data.Aeson (Value, withText)
import Data.Aeson.Types (Parser)
import Data.KindID (KindID)
import Data.KindID qualified as KindID
import Data.KindID.Class (ValidPrefix)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.TypeID qualified as TypeID
import GHC.TypeLits (symbolVal)
import Keiki.ProjectionDomain
( DomainConstructionError,
TextPattern,
textCharSet,
textConcat,
textLiteral,
textRepeatBetween,
)
data IdNormalization = CanonicalLowercase
deriving stock (Eq, Ord, Show)
data IdDomainContract = IdDomainContract
{ idDomainVersion :: !Text,
idDomainPrefix :: !Text,
idDomainSeparator :: !Char,
idDomainSuffixLength :: !Int,
idDomainMaxLength :: !Int,
idDomainNormalization :: !IdNormalization,
idDomainJsonRepresentation :: !Text
}
deriving stock (Eq, Ord, Show)
data IdDomainFailure
= IdDomainNonCanonical
| IdDomainWrongPrefix !Text !Text
| IdDomainMalformed !Text
| IdDomainNotUuidV7 !Text
deriving stock (Eq, Ord, Show)
enforcedIdDomainVersion :: Text
enforcedIdDomainVersion = "keiro-dsl/id-domain/typeid-v7/1"
typeIdV7Domain :: Text -> IdDomainContract
typeIdV7Domain prefix =
IdDomainContract
{ idDomainVersion = enforcedIdDomainVersion,
idDomainPrefix = prefix,
idDomainSeparator = '_',
idDomainSuffixLength = 26,
idDomainMaxLength = if T.null prefix then 26 else T.length prefix + 27,
idDomainNormalization = CanonicalLowercase,
idDomainJsonRepresentation = "canonical-json-text"
}
idDomainAcceptsText :: IdDomainContract -> Text -> Bool
idDomainAcceptsText contract = either (const False) (const True) . validateIdDomainText contract
-- | @mmzk-typeid@ intentionally separates canonical parsing from the UUID
-- version check, so both operations are part of this frozen contract.
validateIdDomainText :: IdDomainContract -> Text -> Either IdDomainFailure ()
validateIdDomainText contract input = do
parsed <- case TypeID.parseText input of
Right value -> Right value
Left reason ->
case TypeID.parseText (T.toLower input) of
Right canonical
| TypeID.toText canonical == T.toLower input -> Left IdDomainNonCanonical
_ -> Left (IdDomainMalformed (T.pack (show reason)))
let actualPrefix = TypeID.getPrefix parsed
if actualPrefix == idDomainPrefix contract
then pure ()
else Left (IdDomainWrongPrefix (idDomainPrefix contract) actualPrefix)
if TypeID.toText parsed == input
then pure ()
else Left IdDomainNonCanonical
maybe (Right ()) (Left . IdDomainNotUuidV7 . T.pack . show) (TypeID.checkTypeID parsed)
-- | Parse a canonical TypeID-v7 whose prefix is reflected in the result type.
-- Keiro's frozen admission policy runs before the dependency constructs the
-- prefix-indexed value, so generated consumers cannot accidentally widen it.
parseKindIdV7Text :: forall prefix. (ValidPrefix prefix) => Text -> Either IdDomainFailure (KindID prefix)
parseKindIdV7Text input = do
validateIdDomainText (typeIdV7Domain expectedPrefix) input
either (Left . IdDomainMalformed . T.pack . show) Right (KindID.parseText @prefix input)
where
expectedPrefix = T.pack (symbolVal (Proxy @prefix))
-- | Aeson parser for generated integration-contract fields. When used with
-- @explicitParseField@, Aeson attaches the owning field key to these stable
-- Keiro admission failures.
parseKindIdV7Value :: forall prefix. (ValidPrefix prefix) => Value -> Parser (KindID prefix)
parseKindIdV7Value = withText "KindID" $ \input ->
either (fail . T.unpack . renderIdDomainFailure) pure (parseKindIdV7Text @prefix input)
renderIdDomainFailure :: IdDomainFailure -> Text
renderIdDomainFailure failure = case failure of
IdDomainNonCanonical -> "TypeID text is not canonical lowercase"
IdDomainWrongPrefix expected actual ->
"TypeID prefix mismatch: expected '" <> expected <> "', found '" <> actual <> "'"
IdDomainMalformed reason -> "malformed TypeID text: " <> reason
IdDomainNotUuidV7 reason -> "TypeID suffix is not UUIDv7: " <> reason
idDomainTextPattern :: IdDomainContract -> Either DomainConstructionError TextPattern
idDomainTextPattern contract = do
prefix <-
textLiteral
( if T.null (idDomainPrefix contract)
then ""
else idDomainPrefix contract <> T.singleton (idDomainSeparator contract)
)
leading <- textCharSet ('0' :| "1234567")
crockford <- textCharSet ('0' :| "123456789abcdefghjkmnpqrstvwxyz")
version <- textCharSet ('e' :| "f")
variant <- textCharSet ('8' :| "9abrstv")
beforeVersion <- textRepeatBetween 9 9 crockford
beforeVariant <- textRepeatBetween 2 2 crockford
afterVariant <- textRepeatBetween 12 12 crockford
pure
( textConcat
( prefix
:| [ leading,
beforeVersion,
version,
beforeVariant,
variant,
afterVariant
]
)
)
idDomainSampleText :: IdDomainContract -> Text
idDomainSampleText contract =
(if T.null (idDomainPrefix contract) then "" else idDomainPrefix contract <> "_")
<> "01h455vb4pex5vsknk084sn02q"