acolyte-codegen-0.1.0.0: src/Acolyte/Codegen/Emit.hs
-- | Haskell code generation from the API IR.
--
-- Produces a complete Haskell module with:
-- * Path types using GHC.TypeLits
-- * Request/response data types with FromJSON/ToJSON
-- * The API type as a promoted list
-- * Requires Auth on secured endpoints
module Acolyte.Codegen.Emit
( emitModule
, EmitConfig (..)
, defaultEmitConfig
) where
import Data.Char (toUpper, toLower, isAlpha, isAlphaNum)
import Data.List (intercalate, nub)
import Data.Text (Text)
import qualified Data.Text as T
import Acolyte.Codegen.IR
-- | Configuration for code emission.
data EmitConfig = EmitConfig
{ emitModuleName :: !Text -- ^ Module name (e.g., "Generated.API")
, emitHandlerStubs :: !Bool -- ^ Generate handler stubs?
} deriving (Show)
-- | Default emit configuration: module name @"Generated.API"@ with handler stubs enabled.
defaultEmitConfig :: EmitConfig
defaultEmitConfig = EmitConfig
{ emitModuleName = "Generated.API"
, emitHandlerStubs = True
}
-- | Generate a complete Haskell module from an API IR.
emitModule :: EmitConfig -> ApiIR -> Text
emitModule cfg api = T.unlines $ concat
[ emitHeader cfg api
, [""]
, emitImports
, [""]
, emitSchemaTypes (apiSchemas api)
, [""]
, emitPathTypes (apiEndpoints api)
, [""]
, emitApiType (apiEndpoints api)
, if emitHandlerStubs cfg
then [""] ++ emitStubs (apiEndpoints api)
else []
]
-- ===================================================================
-- Header
-- ===================================================================
emitHeader :: EmitConfig -> ApiIR -> [Text]
emitHeader cfg api =
[ "-- | Generated from: " <> apiTitle api <> " v" <> apiVersion api
, "--"
, "-- This module was auto-generated by acolyte-codegen."
, "-- Edit the OpenAPI spec and re-run the generator to update."
, "{-# LANGUAGE DataKinds #-}"
, "{-# LANGUAGE TypeFamilies #-}"
, "{-# LANGUAGE TypeOperators #-}"
, "{-# LANGUAGE OverloadedStrings #-}"
, "{-# LANGUAGE DeriveGeneric #-}"
, "{-# LANGUAGE DeriveAnyClass #-}"
, "module " <> emitModuleName cfg <> " where"
]
-- ===================================================================
-- Imports
-- ===================================================================
emitImports :: [Text]
emitImports =
[ "import Data.Aeson (FromJSON, ToJSON)"
, "import Data.Text (Text)"
, "import GHC.Generics (Generic)"
, "import Acolyte.Core"
, "import Acolyte.Server (Json, HandlerFn)"
]
-- ===================================================================
-- Schema types (data declarations)
-- ===================================================================
emitSchemaTypes :: [(Text, SchemaIR)] -> [Text]
emitSchemaTypes schemas = concatMap emitSchemaType named
where
named = [ (n, s) | (n, s@(SchemaObject _ fields)) <- schemas, not (null fields) ]
emitSchemaType :: (Text, SchemaIR) -> [Text]
emitSchemaType (name, SchemaObject _ fields) =
[ "data " <> typeName name <> " = " <> typeName name
, " { " <> T.intercalate "\n , " (map emitField fields)
, " } deriving (Show, Eq, Generic, FromJSON, ToJSON)"
, ""
]
emitSchemaType _ = []
emitField :: FieldIR -> Text
emitField f =
fieldAccessor (fieldName f) <> " :: " <> emitSchemaTypeRef (fieldSchema f)
fieldAccessor :: Text -> Text
fieldAccessor name = T.pack (camelCase (T.unpack name))
emitSchemaTypeRef :: SchemaIR -> Text
emitSchemaTypeRef SchemaString = "Text"
emitSchemaTypeRef SchemaInteger = "Int"
emitSchemaTypeRef SchemaNumber = "Double"
emitSchemaTypeRef SchemaBoolean = "Bool"
emitSchemaTypeRef (SchemaArray s) = "[" <> emitSchemaTypeRef s <> "]"
emitSchemaTypeRef (SchemaRef name) = typeName name
emitSchemaTypeRef (SchemaObject n _) = typeName n
-- ===================================================================
-- Path types
-- ===================================================================
emitPathTypes :: [EndpointIR] -> [Text]
emitPathTypes eps = map emitPathType (nub (map pathId eps))
where
pathId ep = (pathTypeName (epPath ep), epPath ep)
emitPathType (name, segments) =
"type " <> name <> " = '[" <> T.intercalate ", " (map emitSegment segments) <> "]"
emitSegment :: PathSegmentIR -> Text
emitSegment (LitSegment s) = "'Lit \"" <> s <> "\""
emitSegment (CaptureSegment _ t) = "'Capture " <> captureType t
captureType :: Text -> Text
captureType "integer" = "Int"
captureType "number" = "Double"
captureType _ = "Text"
pathTypeName :: [PathSegmentIR] -> Text
pathTypeName segments = T.pack $ concatMap segName segments ++ "Path"
where
segName (LitSegment s) = pascal (T.unpack s)
segName (CaptureSegment n _) = "By" ++ pascal (T.unpack n)
-- ===================================================================
-- API type
-- ===================================================================
emitApiType :: [EndpointIR] -> [Text]
emitApiType [] = ["type API = '[]"]
emitApiType eps =
[ "type API ="
, " '[ " <> T.intercalate "\n , " (map emitEndpoint eps)
, " ]"
]
emitEndpoint :: EndpointIR -> Text
emitEndpoint ep =
let core = emitCoreEndpoint ep
in if epRequiresAuth ep
then "Requires Auth (" <> core <> ")"
else core
emitCoreEndpoint :: EndpointIR -> Text
emitCoreEndpoint ep =
let pathName = pathTypeName (epPath ep)
respType = maybe "Text" emitResponseType (epResponseType ep)
in case epMethod ep of
GET -> "Get " <> pathName <> " (Json " <> respType <> ")"
DELETE -> "Delete " <> pathName <> " (Json " <> respType <> ")"
POST -> "Post " <> pathName <> " " <> emitReqType ep <> " (Json " <> respType <> ")"
PUT -> "Put " <> pathName <> " " <> emitReqType ep <> " (Json " <> respType <> ")"
PATCH -> "Patch " <> pathName <> " " <> emitReqType ep <> " (Json " <> respType <> ")"
HEAD -> "Get " <> pathName <> " (Json " <> respType <> ")"
OPTIONS -> "Get " <> pathName <> " (Json " <> respType <> ")"
emitReqType :: EndpointIR -> Text
emitReqType ep = case epRequestBody ep of
Just (SchemaRef name) -> "(Json " <> typeName name <> ")"
Just _ -> "(Json Value)"
Nothing -> "(Json Value)"
emitResponseType :: SchemaIR -> Text
emitResponseType (SchemaRef name) = typeName name
emitResponseType (SchemaArray s) = "[" <> emitResponseType s <> "]"
emitResponseType SchemaString = "Text"
emitResponseType SchemaInteger = "Int"
emitResponseType SchemaNumber = "Double"
emitResponseType SchemaBoolean = "Bool"
emitResponseType (SchemaObject n _) = typeName n
-- ===================================================================
-- Handler stubs
-- ===================================================================
emitStubs :: [EndpointIR] -> [Text]
emitStubs eps =
[ "-- ====================================================================="
, "-- Handler stubs (implement these)"
, "-- ====================================================================="
, ""
] ++ concatMap emitStub eps
emitStub :: EndpointIR -> [Text]
emitStub ep =
let name = stubName ep
in [ "-- " <> T.pack (show (epMethod ep)) <> " " <> reconstructPath (epPath ep)
, name <> " :: HandlerFn"
, name <> " _parts _body = error \"TODO: implement " <> name <> "\""
, ""
]
stubName :: EndpointIR -> Text
stubName ep = T.pack $ camelCase $
map toLower (show (epMethod ep)) ++ concatMap segWord (epPath ep)
where
segWord (LitSegment s) = pascal (T.unpack s)
segWord (CaptureSegment n _) = "By" ++ pascal (T.unpack n)
reconstructPath :: [PathSegmentIR] -> Text
reconstructPath segs = "/" <> T.intercalate "/" (map segText segs)
where
segText (LitSegment s) = s
segText (CaptureSegment n _) = "{" <> n <> "}"
-- ===================================================================
-- String utilities
-- ===================================================================
typeName :: Text -> Text
typeName = T.pack . pascal . T.unpack
pascal :: String -> String
pascal [] = []
pascal (c:cs) = toUpper c : cs
camelCase :: String -> String
camelCase [] = []
camelCase (c:cs) = toLower c : cs