Add two additional Haskell examples (with their note files)
This commit is contained in:
parent
6787a9cc4f
commit
f517fa533f
25
28-haskell-applicative-validation/README.md
Normal file
25
28-haskell-applicative-validation/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 28-haskell-applicative-validation
|
||||
|
||||
This example shows intermediate Haskell validation with an Applicative error accumulator.
|
||||
|
||||
It includes:
|
||||
|
||||
- a custom `Validation` type that accumulates multiple errors,
|
||||
- a release manifest built from validated fields,
|
||||
- a CLI that reports all invalid fields at once, and
|
||||
- a test suite run by `nix flake check`.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
cabal run
|
||||
cabal run -- service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
cabal test
|
||||
|
||||
nix build
|
||||
./result/bin/mini-validation service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
|
||||
nix run . -- service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
nix flake check
|
||||
```
|
||||
33
28-haskell-applicative-validation/app/Main.hs
Normal file
33
28-haskell-applicative-validation/app/Main.hs
Normal file
@ -0,0 +1,33 @@
|
||||
module Main where
|
||||
|
||||
import MiniValidation.Manifest
|
||||
( Validation (Failure, Success)
|
||||
, readRawManifest
|
||||
, renderManifest
|
||||
, renderValidationErrors
|
||||
, validateManifest
|
||||
)
|
||||
import System.Environment (getArgs)
|
||||
import System.Exit (die)
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
args <- getArgs
|
||||
let inputArgs =
|
||||
case args of
|
||||
[] ->
|
||||
[ "service=api-gateway"
|
||||
, "env=production"
|
||||
, "owners=platform,security"
|
||||
, "replicas=3"
|
||||
, "strategy=canary:20"
|
||||
, "window=22-24"
|
||||
]
|
||||
_ -> args
|
||||
|
||||
case readRawManifest inputArgs of
|
||||
Left err -> die err
|
||||
Right rawManifest ->
|
||||
case validateManifest rawManifest of
|
||||
Failure validationErrors -> die (renderValidationErrors validationErrors)
|
||||
Success manifest -> putStrLn (renderManifest manifest)
|
||||
27
28-haskell-applicative-validation/flake.lock
generated
Normal file
27
28-haskell-applicative-validation/flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
38
28-haskell-applicative-validation/flake.nix
Normal file
38
28-haskell-applicative-validation/flake.nix
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
# Builds a Haskell project that validates a release manifest with an
|
||||
# Applicative validation type that accumulates multiple field errors.
|
||||
description = "A Haskell project for Applicative validation";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
inherit (pkgs) haskellPackages;
|
||||
project = haskellPackages.callCabal2nix "mini-validation" ./. { };
|
||||
checkedProject = pkgs.haskell.lib.doCheck project;
|
||||
in
|
||||
{
|
||||
packages.${system}.default = project;
|
||||
|
||||
apps.${system}.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/mini-validation";
|
||||
meta.description = "Run the Applicative validation example.";
|
||||
};
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
packages = [
|
||||
haskellPackages.ghc
|
||||
pkgs.cabal-install
|
||||
pkgs.haskell-language-server
|
||||
];
|
||||
};
|
||||
|
||||
checks.${system}.test-suite = checkedProject;
|
||||
};
|
||||
}
|
||||
28
28-haskell-applicative-validation/mini-validation.cabal
Normal file
28
28-haskell-applicative-validation/mini-validation.cabal
Normal file
@ -0,0 +1,28 @@
|
||||
cabal-version: 2.4
|
||||
name: mini-validation
|
||||
version: 0.1.0.0
|
||||
build-type: Simple
|
||||
|
||||
library
|
||||
exposed-modules: MiniValidation.Manifest
|
||||
hs-source-dirs: src
|
||||
build-depends:
|
||||
base >=4.14 && <5
|
||||
default-language: Haskell2010
|
||||
|
||||
executable mini-validation
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: app
|
||||
build-depends:
|
||||
base >=4.14 && <5,
|
||||
mini-validation
|
||||
default-language: Haskell2010
|
||||
|
||||
test-suite mini-validation-test
|
||||
type: exitcode-stdio-1.0
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: test
|
||||
build-depends:
|
||||
base >=4.14 && <5,
|
||||
mini-validation
|
||||
default-language: Haskell2010
|
||||
222
28-haskell-applicative-validation/src/MiniValidation/Manifest.hs
Normal file
222
28-haskell-applicative-validation/src/MiniValidation/Manifest.hs
Normal file
@ -0,0 +1,222 @@
|
||||
module MiniValidation.Manifest where
|
||||
|
||||
import Data.Char (isDigit, isLower)
|
||||
import Data.List (group, intercalate)
|
||||
|
||||
data Validation errorValue value
|
||||
= Failure errorValue
|
||||
| Success value
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance Functor (Validation errorValue) where
|
||||
fmap _ (Failure err) = Failure err
|
||||
fmap f (Success value) = Success (f value)
|
||||
|
||||
instance Semigroup errorValue => Applicative (Validation errorValue) where
|
||||
pure = Success
|
||||
Failure leftError <*> Failure rightError = Failure (leftError <> rightError)
|
||||
Failure err <*> _ = Failure err
|
||||
_ <*> Failure err = Failure err
|
||||
Success f <*> Success value = Success (f value)
|
||||
|
||||
data Environment
|
||||
= Staging
|
||||
| Production
|
||||
deriving (Eq, Show)
|
||||
|
||||
data RolloutStrategy
|
||||
= Rolling
|
||||
| Canary Int
|
||||
deriving (Eq, Show)
|
||||
|
||||
data MaintenanceWindow = MaintenanceWindow
|
||||
{ windowStartHour :: Int
|
||||
, windowEndHour :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data ReleaseManifest = ReleaseManifest
|
||||
{ manifestService :: String
|
||||
, manifestEnvironment :: Environment
|
||||
, manifestOwners :: [String]
|
||||
, manifestReplicas :: Int
|
||||
, manifestStrategy :: RolloutStrategy
|
||||
, manifestWindow :: MaintenanceWindow
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data RawManifest = RawManifest
|
||||
{ rawService :: Maybe String
|
||||
, rawEnvironment :: Maybe String
|
||||
, rawOwners :: Maybe String
|
||||
, rawReplicas :: Maybe String
|
||||
, rawStrategy :: Maybe String
|
||||
, rawWindow :: Maybe String
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
emptyRawManifest :: RawManifest
|
||||
emptyRawManifest =
|
||||
RawManifest
|
||||
{ rawService = Nothing
|
||||
, rawEnvironment = Nothing
|
||||
, rawOwners = Nothing
|
||||
, rawReplicas = Nothing
|
||||
, rawStrategy = Nothing
|
||||
, rawWindow = Nothing
|
||||
}
|
||||
|
||||
readRawManifest :: [String] -> Either String RawManifest
|
||||
readRawManifest = foldl step (Right emptyRawManifest)
|
||||
where
|
||||
step (Left err) _ = Left err
|
||||
step (Right manifest) assignment = do
|
||||
(key, value) <- parseAssignment assignment
|
||||
applyAssignment key value manifest
|
||||
|
||||
parseAssignment :: String -> Either String (String, String)
|
||||
parseAssignment input =
|
||||
case break (== '=') input of
|
||||
([], _) -> Left ("expected key=value, got: " ++ input)
|
||||
(_, "") -> Left ("expected key=value, got: " ++ input)
|
||||
(key, '=' : value)
|
||||
| null value -> Left ("missing value for key: " ++ key)
|
||||
| otherwise -> Right (key, value)
|
||||
_ -> Left ("expected key=value, got: " ++ input)
|
||||
|
||||
applyAssignment :: String -> String -> RawManifest -> Either String RawManifest
|
||||
applyAssignment "service" value manifest = Right manifest { rawService = Just value }
|
||||
applyAssignment "env" value manifest = Right manifest { rawEnvironment = Just value }
|
||||
applyAssignment "owners" value manifest = Right manifest { rawOwners = Just value }
|
||||
applyAssignment "replicas" value manifest = Right manifest { rawReplicas = Just value }
|
||||
applyAssignment "strategy" value manifest = Right manifest { rawStrategy = Just value }
|
||||
applyAssignment "window" value manifest = Right manifest { rawWindow = Just value }
|
||||
applyAssignment key _ _ = Left ("unknown field: " ++ key)
|
||||
|
||||
validateManifest :: RawManifest -> Validation [String] ReleaseManifest
|
||||
validateManifest rawManifest =
|
||||
ReleaseManifest
|
||||
<$> validateService (rawService rawManifest)
|
||||
<*> validateEnvironment (rawEnvironment rawManifest)
|
||||
<*> validateOwners (rawOwners rawManifest)
|
||||
<*> validateReplicas (rawReplicas rawManifest)
|
||||
<*> validateStrategy (rawStrategy rawManifest)
|
||||
<*> validateWindow (rawWindow rawManifest)
|
||||
|
||||
validateService :: Maybe String -> Validation [String] String
|
||||
validateService Nothing = Failure ["service is required"]
|
||||
validateService (Just serviceName)
|
||||
| length serviceName < 3 = Failure ["service must be at least 3 characters long"]
|
||||
| not (all validServiceChar serviceName) =
|
||||
Failure ["service must use lowercase letters and hyphens only"]
|
||||
| otherwise = Success serviceName
|
||||
|
||||
validateEnvironment :: Maybe String -> Validation [String] Environment
|
||||
validateEnvironment Nothing = Failure ["env is required"]
|
||||
validateEnvironment (Just "staging") = Success Staging
|
||||
validateEnvironment (Just "production") = Success Production
|
||||
validateEnvironment (Just otherValue) = Failure ["unknown environment: " ++ otherValue]
|
||||
|
||||
validateOwners :: Maybe String -> Validation [String] [String]
|
||||
validateOwners Nothing = Failure ["owners is required"]
|
||||
validateOwners (Just rawOwnerList) =
|
||||
let owners = splitOn ',' rawOwnerList
|
||||
in case () of
|
||||
_ | null owners -> Failure ["owners must contain at least one owner"]
|
||||
| any null owners -> Failure ["owners must not contain empty entries"]
|
||||
| hasDuplicates owners -> Failure ["owners must not contain duplicates"]
|
||||
| otherwise -> Success owners
|
||||
|
||||
validateReplicas :: Maybe String -> Validation [String] Int
|
||||
validateReplicas Nothing = Failure ["replicas is required"]
|
||||
validateReplicas (Just rawValue) =
|
||||
case reads rawValue of
|
||||
[(replicaCount, "")]
|
||||
| replicaCount >= 2 -> Success replicaCount
|
||||
| otherwise -> Failure ["replicas must be at least 2"]
|
||||
_ -> Failure ["replicas must be a whole number"]
|
||||
|
||||
validateStrategy :: Maybe String -> Validation [String] RolloutStrategy
|
||||
validateStrategy Nothing = Failure ["strategy is required"]
|
||||
validateStrategy (Just "rolling") = Success Rolling
|
||||
validateStrategy (Just rawValue) =
|
||||
case break (== ':') rawValue of
|
||||
("canary", ':' : rawPercent) ->
|
||||
case reads rawPercent of
|
||||
[(percent, "")]
|
||||
| percent >= 1 && percent <= 50 -> Success (Canary percent)
|
||||
| otherwise -> Failure ["canary percentage must be between 1 and 50"]
|
||||
_ -> Failure ["canary percentage must be a whole number"]
|
||||
_ -> Failure ["strategy must be rolling or canary:<percent>"]
|
||||
|
||||
validateWindow :: Maybe String -> Validation [String] MaintenanceWindow
|
||||
validateWindow Nothing = Failure ["window is required"]
|
||||
validateWindow (Just rawValue) =
|
||||
case break (== '-') rawValue of
|
||||
(rawStart, '-' : rawEnd)
|
||||
| all isDigits [rawStart, rawEnd] ->
|
||||
case (reads rawStart, reads rawEnd) of
|
||||
([(startHour, "")], [(endHour, "")])
|
||||
| startHour < 0 || endHour > 24 ->
|
||||
Failure ["window hours must stay within 0 and 24"]
|
||||
| startHour >= endHour ->
|
||||
Failure ["window start must be before window end"]
|
||||
| otherwise ->
|
||||
Success
|
||||
MaintenanceWindow
|
||||
{ windowStartHour = startHour
|
||||
, windowEndHour = endHour
|
||||
}
|
||||
_ -> Failure ["window must use whole hours"]
|
||||
| otherwise -> Failure ["window must use whole hours"]
|
||||
_ -> Failure ["window must use the form start-end"]
|
||||
|
||||
renderManifest :: ReleaseManifest -> String
|
||||
renderManifest manifest =
|
||||
intercalate
|
||||
", "
|
||||
[ "service " ++ manifestService manifest
|
||||
, "env " ++ renderEnvironment (manifestEnvironment manifest)
|
||||
, "owners " ++ intercalate "/" (manifestOwners manifest)
|
||||
, "replicas " ++ show (manifestReplicas manifest)
|
||||
, "strategy " ++ renderStrategy (manifestStrategy manifest)
|
||||
, "window " ++ renderWindow (manifestWindow manifest)
|
||||
]
|
||||
|
||||
renderValidationErrors :: [String] -> String
|
||||
renderValidationErrors errors = unlines (map ("- " ++) errors)
|
||||
|
||||
renderEnvironment :: Environment -> String
|
||||
renderEnvironment Staging = "staging"
|
||||
renderEnvironment Production = "production"
|
||||
|
||||
renderStrategy :: RolloutStrategy -> String
|
||||
renderStrategy Rolling = "rolling"
|
||||
renderStrategy (Canary percent) = "canary " ++ show percent ++ "%"
|
||||
|
||||
renderWindow :: MaintenanceWindow -> String
|
||||
renderWindow window = show (windowStartHour window) ++ "-" ++ show (windowEndHour window)
|
||||
|
||||
splitOn :: Char -> String -> [String]
|
||||
splitOn separator = go []
|
||||
where
|
||||
go current [] = [reverse current]
|
||||
go current (nextChar : remainingChars)
|
||||
| nextChar == separator = reverse current : go [] remainingChars
|
||||
| otherwise = go (nextChar : current) remainingChars
|
||||
|
||||
validServiceChar :: Char -> Bool
|
||||
validServiceChar nextChar = isLower nextChar || nextChar == '-'
|
||||
|
||||
isDigits :: String -> Bool
|
||||
isDigits = all isDigit
|
||||
|
||||
hasDuplicates :: Ord value => [value] -> Bool
|
||||
hasDuplicates = any ((> 1) . length) . group . quickSort
|
||||
|
||||
quickSort :: Ord value => [value] -> [value]
|
||||
quickSort [] = []
|
||||
quickSort (pivot : remainingValues) =
|
||||
quickSort [ value | value <- remainingValues, value <= pivot ]
|
||||
++ [pivot]
|
||||
++ quickSort [ value | value <- remainingValues, value > pivot ]
|
||||
47
28-haskell-applicative-validation/test/Main.hs
Normal file
47
28-haskell-applicative-validation/test/Main.hs
Normal file
@ -0,0 +1,47 @@
|
||||
module Main where
|
||||
|
||||
import MiniValidation.Manifest
|
||||
( Environment (Production)
|
||||
, MaintenanceWindow (MaintenanceWindow)
|
||||
, ReleaseManifest (ReleaseManifest)
|
||||
, RolloutStrategy (Canary)
|
||||
, Validation (Failure, Success)
|
||||
, readRawManifest
|
||||
, renderManifest
|
||||
, validateManifest
|
||||
)
|
||||
import System.Exit (die)
|
||||
|
||||
main :: IO ()
|
||||
main =
|
||||
case
|
||||
( readRawManifest
|
||||
[ "service=api-gateway"
|
||||
, "env=production"
|
||||
, "owners=platform,security"
|
||||
, "replicas=3"
|
||||
, "strategy=canary:20"
|
||||
, "window=22-24"
|
||||
]
|
||||
, readRawManifest
|
||||
[ "service=API"
|
||||
, "env=prod"
|
||||
, "owners=platform,platform"
|
||||
, "replicas=1"
|
||||
, "strategy=canary:75"
|
||||
, "window=24-20"
|
||||
]
|
||||
) of
|
||||
( Right validRawManifest
|
||||
, Right invalidRawManifest
|
||||
) ->
|
||||
case (validateManifest validRawManifest, validateManifest invalidRawManifest) of
|
||||
( Success (ReleaseManifest "api-gateway" Production ["platform", "security"] 3 (Canary 20) (MaintenanceWindow 22 24))
|
||||
, Failure validationErrors
|
||||
) | renderManifest
|
||||
(ReleaseManifest "api-gateway" Production ["platform", "security"] 3 (Canary 20) (MaintenanceWindow 22 24))
|
||||
== "service api-gateway, env production, owners platform/security, replicas 3, strategy canary 20%, window 22-24"
|
||||
&& length validationErrors == 6 ->
|
||||
putStrLn "test passed"
|
||||
_ -> die "unexpected validation result"
|
||||
_ -> die "unexpected manifest parse result"
|
||||
25
29-haskell-foldmap-summary/README.md
Normal file
25
29-haskell-foldmap-summary/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 29-haskell-foldmap-summary
|
||||
|
||||
This example shows intermediate Haskell aggregation with `foldMap` and monoids.
|
||||
|
||||
It includes:
|
||||
|
||||
- a structured deployment event type,
|
||||
- a summary type built from monoidal fields,
|
||||
- one `foldMap` pass that aggregates several useful metrics, and
|
||||
- a test suite run by `nix flake check`.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
cabal run
|
||||
cabal run -- api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
cabal test
|
||||
|
||||
nix build
|
||||
./result/bin/mini-summary api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
|
||||
nix run . -- api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
nix flake check
|
||||
```
|
||||
25
29-haskell-foldmap-summary/app/Main.hs
Normal file
25
29-haskell-foldmap-summary/app/Main.hs
Normal file
@ -0,0 +1,25 @@
|
||||
module Main where
|
||||
|
||||
import MiniSummary.Report
|
||||
( parseEvent
|
||||
, renderSummary
|
||||
, summarizeEvents
|
||||
)
|
||||
import System.Environment (getArgs)
|
||||
import System.Exit (die)
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
args <- getArgs
|
||||
let inputArgs =
|
||||
case args of
|
||||
[] ->
|
||||
[ "api:production:succeeded:3:platform,security"
|
||||
, "worker:staging:failed-db-lock:1:ops"
|
||||
, "ui:production:cancelled:2:frontend"
|
||||
]
|
||||
_ -> args
|
||||
|
||||
case traverse parseEvent inputArgs of
|
||||
Left err -> die err
|
||||
Right events -> putStrLn (renderSummary (summarizeEvents events))
|
||||
27
29-haskell-foldmap-summary/flake.lock
generated
Normal file
27
29-haskell-foldmap-summary/flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
38
29-haskell-foldmap-summary/flake.nix
Normal file
38
29-haskell-foldmap-summary/flake.nix
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
# Builds a Haskell project that summarizes deployment events with foldMap
|
||||
# and monoidal summary values.
|
||||
description = "A Haskell project for foldMap summaries";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
inherit (pkgs) haskellPackages;
|
||||
project = haskellPackages.callCabal2nix "mini-summary" ./. { };
|
||||
checkedProject = pkgs.haskell.lib.doCheck project;
|
||||
in
|
||||
{
|
||||
packages.${system}.default = project;
|
||||
|
||||
apps.${system}.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/mini-summary";
|
||||
meta.description = "Run the foldMap summary example.";
|
||||
};
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
packages = [
|
||||
haskellPackages.ghc
|
||||
pkgs.cabal-install
|
||||
pkgs.haskell-language-server
|
||||
];
|
||||
};
|
||||
|
||||
checks.${system}.test-suite = checkedProject;
|
||||
};
|
||||
}
|
||||
30
29-haskell-foldmap-summary/mini-summary.cabal
Normal file
30
29-haskell-foldmap-summary/mini-summary.cabal
Normal file
@ -0,0 +1,30 @@
|
||||
cabal-version: 2.4
|
||||
name: mini-summary
|
||||
version: 0.1.0.0
|
||||
build-type: Simple
|
||||
|
||||
library
|
||||
exposed-modules: MiniSummary.Report
|
||||
hs-source-dirs: src
|
||||
build-depends:
|
||||
base >=4.14 && <5,
|
||||
containers
|
||||
default-language: Haskell2010
|
||||
|
||||
executable mini-summary
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: app
|
||||
build-depends:
|
||||
base >=4.14 && <5,
|
||||
mini-summary
|
||||
default-language: Haskell2010
|
||||
|
||||
test-suite mini-summary-test
|
||||
type: exitcode-stdio-1.0
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: test
|
||||
build-depends:
|
||||
base >=4.14 && <5,
|
||||
containers,
|
||||
mini-summary
|
||||
default-language: Haskell2010
|
||||
190
29-haskell-foldmap-summary/src/MiniSummary/Report.hs
Normal file
190
29-haskell-foldmap-summary/src/MiniSummary/Report.hs
Normal file
@ -0,0 +1,190 @@
|
||||
module MiniSummary.Report where
|
||||
|
||||
import Data.List (intercalate)
|
||||
import qualified Data.Set as Set
|
||||
import Data.Set (Set)
|
||||
|
||||
data Environment
|
||||
= Staging
|
||||
| Production
|
||||
deriving (Eq, Ord, Show)
|
||||
|
||||
data DeploymentStatus
|
||||
= Succeeded
|
||||
| Failed String
|
||||
| Cancelled
|
||||
deriving (Eq, Ord, Show)
|
||||
|
||||
data DeploymentEvent = DeploymentEvent
|
||||
{ eventService :: String
|
||||
, eventEnvironment :: Environment
|
||||
, eventStatus :: DeploymentStatus
|
||||
, eventReplicas :: Int
|
||||
, eventOwners :: [String]
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
newtype Count = Count
|
||||
{ unCount :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance Semigroup Count where
|
||||
Count leftValue <> Count rightValue = Count (leftValue + rightValue)
|
||||
|
||||
instance Monoid Count where
|
||||
mempty = Count 0
|
||||
|
||||
newtype LargestReplicas = LargestReplicas
|
||||
{ unLargestReplicas :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance Semigroup LargestReplicas where
|
||||
LargestReplicas leftValue <> LargestReplicas rightValue =
|
||||
LargestReplicas (max leftValue rightValue)
|
||||
|
||||
instance Monoid LargestReplicas where
|
||||
mempty = LargestReplicas 0
|
||||
|
||||
newtype CancelledSeen = CancelledSeen
|
||||
{ unCancelledSeen :: Bool
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance Semigroup CancelledSeen where
|
||||
CancelledSeen leftValue <> CancelledSeen rightValue = CancelledSeen (leftValue || rightValue)
|
||||
|
||||
instance Monoid CancelledSeen where
|
||||
mempty = CancelledSeen False
|
||||
|
||||
data DeploymentSummary = DeploymentSummary
|
||||
{ totalDeployments :: Count
|
||||
, productionDeployments :: Count
|
||||
, totalReplicas :: Count
|
||||
, failedServices :: Set String
|
||||
, activeOwners :: Set String
|
||||
, cancelledSeen :: CancelledSeen
|
||||
, largestReplicaCount :: LargestReplicas
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance Semigroup DeploymentSummary where
|
||||
leftSummary <> rightSummary =
|
||||
DeploymentSummary
|
||||
{ totalDeployments = totalDeployments leftSummary <> totalDeployments rightSummary
|
||||
, productionDeployments = productionDeployments leftSummary <> productionDeployments rightSummary
|
||||
, totalReplicas = totalReplicas leftSummary <> totalReplicas rightSummary
|
||||
, failedServices = failedServices leftSummary <> failedServices rightSummary
|
||||
, activeOwners = activeOwners leftSummary <> activeOwners rightSummary
|
||||
, cancelledSeen = cancelledSeen leftSummary <> cancelledSeen rightSummary
|
||||
, largestReplicaCount = largestReplicaCount leftSummary <> largestReplicaCount rightSummary
|
||||
}
|
||||
|
||||
instance Monoid DeploymentSummary where
|
||||
mempty =
|
||||
DeploymentSummary
|
||||
{ totalDeployments = mempty
|
||||
, productionDeployments = mempty
|
||||
, totalReplicas = mempty
|
||||
, failedServices = mempty
|
||||
, activeOwners = mempty
|
||||
, cancelledSeen = mempty
|
||||
, largestReplicaCount = mempty
|
||||
}
|
||||
|
||||
parseEvent :: String -> Either String DeploymentEvent
|
||||
parseEvent rawEvent =
|
||||
case splitOn ':' rawEvent of
|
||||
[serviceName, environmentName, statusName, rawReplicas, rawOwners] -> do
|
||||
environment <- parseEnvironment environmentName
|
||||
status <- parseStatus statusName
|
||||
replicas <- parseReplicas rawReplicas
|
||||
let owners = splitOn ',' rawOwners
|
||||
if any null owners then
|
||||
Left "owners must not contain empty entries"
|
||||
else
|
||||
Right
|
||||
DeploymentEvent
|
||||
{ eventService = serviceName
|
||||
, eventEnvironment = environment
|
||||
, eventStatus = status
|
||||
, eventReplicas = replicas
|
||||
, eventOwners = owners
|
||||
}
|
||||
_ -> Left ("expected <service>:<environment>:<status>:<replicas>:<owners>, got: " ++ rawEvent)
|
||||
|
||||
parseEnvironment :: String -> Either String Environment
|
||||
parseEnvironment "staging" = Right Staging
|
||||
parseEnvironment "production" = Right Production
|
||||
parseEnvironment otherValue = Left ("unknown environment: " ++ otherValue)
|
||||
|
||||
parseStatus :: String -> Either String DeploymentStatus
|
||||
parseStatus "succeeded" = Right Succeeded
|
||||
parseStatus "cancelled" = Right Cancelled
|
||||
parseStatus rawStatus =
|
||||
case splitOn '-' rawStatus of
|
||||
"failed" : reasonParts
|
||||
| not (null reasonParts) -> Right (Failed (intercalate "-" reasonParts))
|
||||
_ -> Left ("unknown status: " ++ rawStatus)
|
||||
|
||||
parseReplicas :: String -> Either String Int
|
||||
parseReplicas rawReplicas =
|
||||
case reads rawReplicas of
|
||||
[(replicas, "")]
|
||||
| replicas > 0 -> Right replicas
|
||||
| otherwise -> Left "replicas must be greater than zero"
|
||||
_ -> Left ("invalid replica count: " ++ rawReplicas)
|
||||
|
||||
summarizeEvents :: [DeploymentEvent] -> DeploymentSummary
|
||||
summarizeEvents = foldMap summarizeEvent
|
||||
|
||||
summarizeEvent :: DeploymentEvent -> DeploymentSummary
|
||||
summarizeEvent deploymentEvent =
|
||||
DeploymentSummary
|
||||
{ totalDeployments = Count 1
|
||||
, productionDeployments =
|
||||
case eventEnvironment deploymentEvent of
|
||||
Production -> Count 1
|
||||
Staging -> Count 0
|
||||
, totalReplicas = Count (eventReplicas deploymentEvent)
|
||||
, failedServices =
|
||||
case eventStatus deploymentEvent of
|
||||
Failed _ -> Set.singleton (eventService deploymentEvent)
|
||||
_ -> Set.empty
|
||||
, activeOwners = Set.fromList (eventOwners deploymentEvent)
|
||||
, cancelledSeen =
|
||||
case eventStatus deploymentEvent of
|
||||
Cancelled -> CancelledSeen True
|
||||
_ -> CancelledSeen False
|
||||
, largestReplicaCount = LargestReplicas (eventReplicas deploymentEvent)
|
||||
}
|
||||
|
||||
renderSummary :: DeploymentSummary -> String
|
||||
renderSummary summary =
|
||||
unlines
|
||||
[ "total deployments: " ++ show (unCount (totalDeployments summary))
|
||||
, "production deployments: " ++ show (unCount (productionDeployments summary))
|
||||
, "total replicas: " ++ show (unCount (totalReplicas summary))
|
||||
, "largest rollout: " ++ show (unLargestReplicas (largestReplicaCount summary))
|
||||
, "cancelled seen: " ++ renderBool (unCancelledSeen (cancelledSeen summary))
|
||||
, "failed services: " ++ renderSet (failedServices summary)
|
||||
, "owners: " ++ renderSet (activeOwners summary)
|
||||
]
|
||||
|
||||
renderBool :: Bool -> String
|
||||
renderBool True = "yes"
|
||||
renderBool False = "no"
|
||||
|
||||
renderSet :: Set String -> String
|
||||
renderSet values
|
||||
| Set.null values = "none"
|
||||
| otherwise = intercalate ", " (Set.toAscList values)
|
||||
|
||||
splitOn :: Char -> String -> [String]
|
||||
splitOn separator = go []
|
||||
where
|
||||
go current [] = [reverse current]
|
||||
go current (nextChar : remainingChars)
|
||||
| nextChar == separator = reverse current : go [] remainingChars
|
||||
| otherwise = go (nextChar : current) remainingChars
|
||||
38
29-haskell-foldmap-summary/test/Main.hs
Normal file
38
29-haskell-foldmap-summary/test/Main.hs
Normal file
@ -0,0 +1,38 @@
|
||||
module Main where
|
||||
|
||||
import MiniSummary.Report
|
||||
( Count (Count)
|
||||
, DeploymentSummary (..)
|
||||
, LargestReplicas (LargestReplicas)
|
||||
, parseEvent
|
||||
, renderSummary
|
||||
, summarizeEvents
|
||||
)
|
||||
import qualified Data.Set as Set
|
||||
import System.Exit (die)
|
||||
|
||||
main :: IO ()
|
||||
main =
|
||||
case
|
||||
traverse
|
||||
parseEvent
|
||||
[ "api:production:succeeded:3:platform,security"
|
||||
, "worker:staging:failed-db-lock:1:ops"
|
||||
, "ui:production:cancelled:2:frontend"
|
||||
] of
|
||||
Left err -> die err
|
||||
Right events ->
|
||||
case summarizeEvents events of
|
||||
DeploymentSummary
|
||||
{ totalDeployments = Count 3
|
||||
, productionDeployments = Count 2
|
||||
, totalReplicas = Count 6
|
||||
, failedServices = failedServicesSet
|
||||
, activeOwners = ownerSet
|
||||
, largestReplicaCount = LargestReplicas 3
|
||||
}
|
||||
| failedServicesSet == Set.fromList ["worker"]
|
||||
&& ownerSet == Set.fromList ["frontend", "ops", "platform", "security"]
|
||||
&& "cancelled seen: yes" `elem` lines (renderSummary (summarizeEvents events)) ->
|
||||
putStrLn "test passed"
|
||||
_ -> die "unexpected foldMap summary result"
|
||||
@ -19,6 +19,8 @@ This note links the Haskell examples in a suggested order from first project str
|
||||
11. `25-haskell-state/`: pure stateful planning with global and per-environment counters
|
||||
12. `26-haskell-quickcheck/`: property testing for a non-trivial normalization function
|
||||
13. `27-haskell-aeson-roundtrip/`: explicit JSON instances and round-trip checks
|
||||
14. `28-haskell-applicative-validation/`: accumulated field validation with an Applicative error type
|
||||
15. `29-haskell-foldmap-summary/`: monoidal event aggregation with one `foldMap` pass
|
||||
|
||||
---
|
||||
|
||||
@ -37,6 +39,8 @@ This note links the Haskell examples in a suggested order from first project str
|
||||
- `25-haskell-state/`: how to thread evolving planning state without leaving pure code
|
||||
- `26-haskell-quickcheck/`: how to test invariants across many generated inputs
|
||||
- `27-haskell-aeson-roundtrip/`: how to keep domain values and JSON formats aligned
|
||||
- `28-haskell-applicative-validation/`: how to report several field errors without stopping at the first one
|
||||
- `29-haskell-foldmap-summary/`: how to express batch aggregation through monoidal summary fragments
|
||||
|
||||
---
|
||||
|
||||
@ -55,3 +59,5 @@ This note links the Haskell examples in a suggested order from first project str
|
||||
- `notes/028-haskell-state.md`
|
||||
- `notes/029-haskell-quickcheck.md`
|
||||
- `notes/030-haskell-aeson-roundtrip.md`
|
||||
- `notes/031-haskell-applicative-validation.md`
|
||||
- `notes/032-haskell-foldmap-summary.md`
|
||||
|
||||
79
notes/031-haskell-applicative-validation.md
Normal file
79
notes/031-haskell-applicative-validation.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Haskell Applicative Validation
|
||||
|
||||
This note covers `28-haskell-applicative-validation/`, which validates a release manifest with a custom `Validation` type that accumulates several
|
||||
field errors at once.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why This Is Not Just `Either`
|
||||
|
||||
`Either` stops at the first failure. That is useful for many workflows, but it is not always the best fit for validating a form-like input.
|
||||
|
||||
This example wants a different behavior:
|
||||
|
||||
- validate `service`,
|
||||
- validate `env`,
|
||||
- validate `owners`,
|
||||
- validate `replicas`,
|
||||
- validate `strategy`, and
|
||||
- validate `window`,
|
||||
|
||||
and report every invalid field in one pass.
|
||||
|
||||
That is why the example uses a `Validation` type with an `Applicative` instance that combines errors through `Semigroup`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the Accumulation Happens
|
||||
|
||||
The important function is:
|
||||
|
||||
```haskell
|
||||
validateManifest :: RawManifest -> Validation [String] ReleaseManifest
|
||||
```
|
||||
|
||||
It builds the final manifest with applicative style:
|
||||
|
||||
```haskell
|
||||
ReleaseManifest
|
||||
<$> validateService ...
|
||||
<*> validateEnvironment ...
|
||||
<*> validateOwners ...
|
||||
<*> validateReplicas ...
|
||||
<*> validateStrategy ...
|
||||
<*> validateWindow ...
|
||||
```
|
||||
|
||||
Each field validator can fail independently, and the `Applicative` instance combines all the resulting error lists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why the Input Is Split into Two Stages
|
||||
|
||||
The example still parses `key=value` assignments with `Either`.
|
||||
|
||||
That keeps syntax errors separate from semantic validation:
|
||||
|
||||
- malformed assignments such as `service` or `owners=` fail immediately, and
|
||||
- well-shaped assignments become a `RawManifest` that the validation layer can inspect field by field.
|
||||
|
||||
This separation keeps the example focused. The interesting part is accumulated validation, not ad hoc string parsing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Commands to Try
|
||||
|
||||
```bash
|
||||
cd 28-haskell-applicative-validation
|
||||
|
||||
nix develop
|
||||
cabal run
|
||||
cabal run -- service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
cabal test
|
||||
|
||||
nix build
|
||||
./result/bin/mini-validation service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
|
||||
nix run . -- service=api-gateway env=production owners=platform,security replicas=3 strategy=canary:20 window=22-24
|
||||
nix flake check
|
||||
```
|
||||
71
notes/032-haskell-foldmap-summary.md
Normal file
71
notes/032-haskell-foldmap-summary.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Haskell foldMap Summaries
|
||||
|
||||
This note covers `29-haskell-foldmap-summary/`, which summarizes deployment events with one `foldMap` pass into a monoidal report value.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why `foldMap` Fits This Problem
|
||||
|
||||
Each deployment event contributes a small piece of information:
|
||||
|
||||
- one deployment count,
|
||||
- maybe one production count,
|
||||
- some replica count,
|
||||
- maybe one failed service,
|
||||
- some owners,
|
||||
- maybe one cancellation flag, and
|
||||
- one candidate for the largest rollout.
|
||||
|
||||
Those pieces all combine associatively, which makes the problem a good fit for monoids.
|
||||
|
||||
The example expresses that directly:
|
||||
|
||||
```haskell
|
||||
summarizeEvents :: [DeploymentEvent] -> DeploymentSummary
|
||||
summarizeEvents = foldMap summarizeEvent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. What the Summary Type Encodes
|
||||
|
||||
`DeploymentSummary` is not one big counter. It is a record of smaller monoidal fields:
|
||||
|
||||
- `Count` for totals,
|
||||
- `Set String` for failed services and owners,
|
||||
- `CancelledSeen` for a yes-or-no flag, and
|
||||
- `LargestReplicas` for the maximum rollout size.
|
||||
|
||||
That is the main teaching point: when each field has a lawful combine operation, the whole summary can combine cleanly too.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why This Is Better Than a Manual Loop
|
||||
|
||||
The example could have used an explicit left fold and updated every field by hand inside the loop.
|
||||
|
||||
`foldMap` is cleaner here because it splits the work into two precise ideas:
|
||||
|
||||
1. how one event becomes a summary fragment, and
|
||||
2. how summary fragments combine.
|
||||
|
||||
That makes the aggregation easier to extend without tangling the parsing and report logic.
|
||||
|
||||
---
|
||||
|
||||
## 4. Commands to Try
|
||||
|
||||
```bash
|
||||
cd 29-haskell-foldmap-summary
|
||||
|
||||
nix develop
|
||||
cabal run
|
||||
cabal run -- api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
cabal test
|
||||
|
||||
nix build
|
||||
./result/bin/mini-summary api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
|
||||
nix run . -- api:production:succeeded:3:platform,security worker:staging:failed-db-lock:1:ops ui:production:cancelled:2:frontend
|
||||
nix flake check
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user