@re-shell/cli
Version:
Full-stack development platform uniting microservices and microfrontends. Build complete applications with .NET (ASP.NET Core Web API, Minimal API), Java (Spring Boot, Quarkus, Micronaut, Vert.x), Rust (Actix-Web, Warp, Rocket, Axum), Python (FastAPI, Dja
1,587 lines (1,328 loc) • 43.9 kB
JavaScript
"use strict";
/**
* Scotty Framework Template Generator
* A Haskell web framework inspired by Ruby's Sinatra
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScottyGenerator = void 0;
const haskell_base_generator_1 = require("./haskell-base-generator");
const fs_1 = require("fs");
const path = __importStar(require("path"));
class ScottyGenerator extends haskell_base_generator_1.HaskellBackendGenerator {
constructor() {
super('Scotty');
}
getFrameworkDependencies() {
return [
'scotty: ^0.12',
'wai: ^3.2',
'wai-extra: ^3.1',
'wai-cors: ^0.2',
'warp: ^3.3',
'http-types: ^0.12',
'aeson: ^2.1',
'text: ^2.0',
'bytestring: ^0.11',
'transformers: ^0.6',
'mtl: ^2.3',
'postgresql-simple: ^0.6',
'resource-pool: ^0.4',
'configurator: ^0.3',
'jwt: ^0.11',
'bcrypt: ^0.0.11',
'time: ^1.12',
'uuid: ^1.3',
'random: ^1.2',
'containers: ^0.6',
'unordered-containers: ^0.2',
'case-insensitive: ^1.2',
'network: ^3.1',
'async: ^2.2',
'stm: ^2.5',
'exceptions: ^0.10',
'lifted-base: ^0.2',
'monad-control: ^1.0',
'safe: ^0.3',
'parsec: ^3.1'
];
}
getExtraDeps() {
return [];
}
async generateFrameworkFiles(projectPath, options) {
// Generate main application
await this.generateMainApp(projectPath, options);
// Generate app module
await this.generateApp(projectPath);
// Generate routes
await this.generateRoutes(projectPath);
// Generate controllers
await this.generateControllers(projectPath);
// Generate middleware
await this.generateMiddleware(projectPath);
// Generate models
await this.generateModels(projectPath);
// Generate database
await this.generateDatabase(projectPath);
// Generate services
await this.generateServices(projectPath);
// Generate config
await this.generateConfig(projectPath, options);
// Generate utilities
await this.generateUtilities(projectPath);
}
async generateMainApp(projectPath, options) {
const mainContent = `{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Concurrent (forkIO)
import Control.Monad (void)
import Data.Text.Lazy (Text)
import Network.Wai.Handler.Warp (run)
import Network.Wai.Middleware.Cors
import Network.Wai.Middleware.RequestLogger (logStdoutDev)
import Web.Scotty
import App
import Config
import Database
import Middleware.Auth
import Middleware.Error
import Routes
main :: IO ()
main = do
-- Load configuration
config <- loadConfig "config/app.conf"
-- Initialize database
pool <- initDB config
-- Run migrations
runMigrations pool
-- Create app state
appState <- createAppState config pool
-- Start background workers
void $ forkIO $ startWorkers appState
let port = configPort config
env = configEnv config
putStrLn $ "Starting Scotty server on port " ++ show port ++ " in " ++ show env ++ " mode"
-- Create Scotty app
scottyApp <- scottyAppT (runAppM appState) $ do
-- Middleware
when (env == Development) $ do
middleware logStdoutDev
middleware simpleCors
middleware errorHandler
middleware $ authMiddleware (configJwtSecret config)
-- Routes
routes
-- Run the app
run port scottyApp
startWorkers :: AppState -> IO ()
startWorkers appState = do
-- Start background job processor
-- Start metrics collector
-- Start cache warmer
return ()
`;
await fs_1.promises.writeFile(path.join(projectPath, 'app', 'Main.hs'), mainContent);
}
async generateApp(projectPath) {
const appContent = `{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module App where
import Control.Monad.Reader
import Control.Monad.Except
import Data.Pool
import Database.PostgreSQL.Simple
import Network.HTTP.Types.Status
import Web.Scotty.Trans
import Config
-- Application State
data AppState = AppState
{ appConfig :: Config
, appConnPool :: Pool Connection
, appLogger :: Logger
}
-- Application Monad
newtype AppM a = AppM
{ unAppM :: ReaderT AppState (ExceptT AppError IO) a
} deriving ( Functor
, Applicative
, Monad
, MonadIO
, MonadReader AppState
, MonadError AppError
)
-- Application Error
data AppError = AppError
{ errorStatus :: Status
, errorMessage :: Text
, errorDetails :: Maybe Value
} deriving (Show)
-- Run AppM in IO
runAppM :: AppState -> AppM a -> IO (Either AppError a)
runAppM state app = runExceptT $ runReaderT (unAppM app) state
-- Create app state
createAppState :: Config -> Pool Connection -> IO AppState
createAppState config pool = do
logger <- createLogger (configLogLevel config)
return AppState
{ appConfig = config
, appConnPool = pool
, appLogger = logger
}
-- Get database connection from pool
withDB :: (Connection -> IO a) -> AppM a
withDB action = do
pool <- asks appConnPool
liftIO $ withResource pool action
-- Logging
data LogLevel = Debug | Info | Warning | Error
deriving (Show, Eq, Ord)
data Logger = Logger
{ logLevel :: LogLevel
, logAction :: LogLevel -> Text -> IO ()
}
createLogger :: LogLevel -> IO Logger
createLogger level = return Logger
{ logLevel = level
, logAction = \\lvl msg ->
when (lvl >= level) $
putStrLn $ "[" ++ show lvl ++ "] " ++ show msg
}
logDebug, logInfo, logWarning, logError :: Text -> AppM ()
logDebug msg = do
logger <- asks appLogger
liftIO $ logAction logger Debug msg
logInfo msg = do
logger <- asks appLogger
liftIO $ logAction logger Info msg
logWarning msg = do
logger <- asks appLogger
liftIO $ logAction logger Warning msg
logError msg = do
logger <- asks appLogger
liftIO $ logAction logger Error msg
-- Error helpers
notFoundError :: Text -> AppError
notFoundError msg = AppError status404 msg Nothing
badRequestError :: Text -> AppError
badRequestError msg = AppError status400 msg Nothing
unauthorizedError :: Text -> AppError
unauthorizedError msg = AppError status401 msg Nothing
forbiddenError :: Text -> AppError
forbiddenError msg = AppError status403 msg Nothing
internalError :: Text -> AppError
internalError msg = AppError status500 msg Nothing
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'App.hs'), appContent);
}
async generateRoutes(projectPath) {
const routesContent = `{-# LANGUAGE OverloadedStrings #-}
module Routes where
import Web.Scotty.Trans
import App
import Controllers.Auth
import Controllers.User
import Controllers.Health
import Middleware.Auth (requireAuth)
routes :: ScottyT Text AppM ()
routes = do
-- Health check
get "/health" healthCheck
get "/api/v1/health" healthCheck
-- Authentication routes
post "/api/v1/auth/register" register
post "/api/v1/auth/login" login
post "/api/v1/auth/refresh" refreshToken
-- Protected routes
get "/api/v1/auth/me" $ requireAuth getMe
post "/api/v1/auth/logout" $ requireAuth logout
-- User routes
get "/api/v1/users" $ requireAuth listUsers
get "/api/v1/users/:id" $ requireAuth getUser
put "/api/v1/users/:id" $ requireAuth updateUser
delete "/api/v1/users/:id" $ requireAuth deleteUser
-- Admin routes
post "/api/v1/admin/users" $ requireAuth $ requireAdmin createUser
-- Static files
get "/" $ do
setHeader "Content-Type" "text/html"
html "<h1>Scotty API Server</h1><p>Visit <a href='/api/v1/health'>/api/v1/health</a> for health check.</p>"
-- 404 handler
notFound $ do
status status404
json $ object
[ "error" .= ("Not Found" :: Text)
, "message" .= ("The requested resource was not found" :: Text)
]
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Routes.hs'), routesContent);
}
async generateControllers(projectPath) {
await fs_1.promises.mkdir(path.join(projectPath, 'src', 'Controllers'), { recursive: true });
// Health controller
const healthContent = `{-# LANGUAGE OverloadedStrings #-}
module Controllers.Health where
import Data.Time.Clock (getCurrentTime)
import Web.Scotty.Trans
import App
import Database
healthCheck :: ActionT Text AppM ()
healthCheck = do
currentTime <- liftIO getCurrentTime
-- Check database connection
dbStatus <- lift $ checkDatabaseConnection
let health = object
[ "status" .= ("healthy" :: Text)
, "timestamp" .= currentTime
, "version" .= ("1.0.0" :: Text)
, "services" .= object
[ "database" .= if dbStatus then "up" else "down" :: Text
]
]
json health
checkDatabaseConnection :: AppM Bool
checkDatabaseConnection = do
result <- try $ withDB $ \\conn -> do
[Only count] <- query_ conn "SELECT 1" :: IO [Only Int]
return $ count == 1
case result of
Left (e :: SomeException) -> do
logError $ "Database health check failed: " <> pack (show e)
return False
Right success -> return success
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Controllers', 'Health.hs'), healthContent);
// Auth controller
const authContent = `{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Controllers.Auth where
import Control.Monad.IO.Class (liftIO)
import Crypto.BCrypt
import Data.Time.Clock
import Data.UUID.V4 (nextRandom)
import Web.Scotty.Trans
import App
import Models.User
import Services.JWT
import Services.User
-- Register new user
register :: ActionT Text AppM ()
register = do
RegisterRequest{..} <- jsonData
-- Validate input
when (T.length registerEmail < 3) $
raise $ badRequestError "Email too short"
when (T.length registerPassword < 8) $
raise $ badRequestError "Password must be at least 8 characters"
-- Check if user exists
existingUser <- lift $ getUserByEmail registerEmail
case existingUser of
Just _ -> raise $ badRequestError "Email already registered"
Nothing -> return ()
-- Hash password
hashedPassword <- liftIO $ hashPasswordUsingPolicy slowerBcryptHashingPolicy (encodeUtf8 registerPassword)
case hashedPassword of
Nothing -> raise $ internalError "Failed to hash password"
Just hash -> do
-- Create user
userId <- liftIO nextRandom
now <- liftIO getCurrentTime
let newUser = User
{ userId = userId
, userEmail = registerEmail
, userPasswordHash = decodeUtf8 hash
, userName = registerName
, userCreatedAt = now
, userUpdatedAt = now
}
-- Save user
lift $ createUser newUser
-- Generate tokens
config <- lift $ asks appConfig
accessToken <- liftIO $ generateAccessToken config userId
refreshToken <- liftIO $ generateRefreshToken config userId
-- Return response
json $ object
[ "user" .= newUser
, "accessToken" .= accessToken
, "refreshToken" .= refreshToken
]
-- Login user
login :: ActionT Text AppM ()
login = do
LoginRequest{..} <- jsonData
-- Find user
maybeUser <- lift $ getUserByEmail loginEmail
user <- case maybeUser of
Nothing -> raise $ unauthorizedError "Invalid credentials"
Just u -> return u
-- Verify password
let valid = validatePassword (encodeUtf8 $ userPasswordHash user) (encodeUtf8 loginPassword)
unless valid $
raise $ unauthorizedError "Invalid credentials"
-- Generate tokens
config <- lift $ asks appConfig
accessToken <- liftIO $ generateAccessToken config (userId user)
refreshToken <- liftIO $ generateRefreshToken config (userId user)
-- Return response
json $ object
[ "user" .= user
, "accessToken" .= accessToken
, "refreshToken" .= refreshToken
]
-- Get current user
getMe :: ActionT Text AppM ()
getMe = do
userId <- getUserId
maybeUser <- lift $ getUserById userId
user <- case maybeUser of
Nothing -> raise $ notFoundError "User not found"
Just u -> return u
json $ object ["user" .= user]
-- Logout user
logout :: ActionT Text AppM ()
logout = do
-- In a real app, you might want to invalidate the refresh token
json $ object ["message" .= ("Logged out successfully" :: Text)]
-- Refresh access token
refreshToken :: ActionT Text AppM ()
refreshToken = do
RefreshRequest{..} <- jsonData
config <- lift $ asks appConfig
claims <- case verifyRefreshToken config refreshRequestToken of
Left err -> raise $ unauthorizedError $ "Invalid refresh token: " <> err
Right c -> return c
-- Generate new access token
newAccessToken <- liftIO $ generateAccessToken config (jwtUserId claims)
json $ object
[ "accessToken" .= newAccessToken
]
-- Request types
data RegisterRequest = RegisterRequest
{ registerEmail :: Text
, registerPassword :: Text
, registerName :: Text
} deriving (Show)
instance FromJSON RegisterRequest where
parseJSON = withObject "RegisterRequest" $ \\v -> RegisterRequest
<$> v .: "email"
<*> v .: "password"
<*> v .: "name"
data LoginRequest = LoginRequest
{ loginEmail :: Text
, loginPassword :: Text
} deriving (Show)
instance FromJSON LoginRequest where
parseJSON = withObject "LoginRequest" $ \\v -> LoginRequest
<$> v .: "email"
<*> v .: "password"
data RefreshRequest = RefreshRequest
{ refreshRequestToken :: Text
} deriving (Show)
instance FromJSON RefreshRequest where
parseJSON = withObject "RefreshRequest" $ \\v -> RefreshRequest
<$> v .: "refreshToken"
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Controllers', 'Auth.hs'), authContent);
// User controller
const userContent = `{-# LANGUAGE OverloadedStrings #-}
module Controllers.User where
import Control.Monad (when)
import Data.UUID (fromText)
import Web.Scotty.Trans
import App
import Models.User
import Services.User
-- List all users
listUsers :: ActionT Text AppM ()
listUsers = do
page <- param "page" \`rescue\` const (return 1)
limit <- param "limit" \`rescue\` const (return 10)
let offset = (page - 1) * limit
users <- lift $ getUsers limit offset
total <- lift $ getUserCount
json $ object
[ "users" .= users
, "pagination" .= object
[ "page" .= page
, "limit" .= limit
, "total" .= total
, "pages" .= ceiling (fromIntegral total / fromIntegral limit :: Double)
]
]
-- Get user by ID
getUser :: ActionT Text AppM ()
getUser = do
userIdParam <- param "id"
userId <- case fromText userIdParam of
Nothing -> raise $ badRequestError "Invalid user ID format"
Just uid -> return uid
maybeUser <- lift $ getUserById userId
user <- case maybeUser of
Nothing -> raise $ notFoundError "User not found"
Just u -> return u
json $ object ["user" .= user]
-- Update user
updateUser :: ActionT Text AppM ()
updateUser = do
currentUserId <- getUserId
userIdParam <- param "id"
targetUserId <- case fromText userIdParam of
Nothing -> raise $ badRequestError "Invalid user ID format"
Just uid -> return uid
-- Check permission (users can only update themselves unless admin)
when (currentUserId /= targetUserId) $ do
isAdmin <- checkIsAdmin
unless isAdmin $
raise $ forbiddenError "You can only update your own profile"
-- Get update data
UpdateUserRequest{..} <- jsonData
-- Update user
updated <- lift $ updateUserDetails targetUserId updateName updateEmail
case updated of
Nothing -> raise $ notFoundError "User not found"
Just user -> json $ object ["user" .= user]
-- Delete user
deleteUser :: ActionT Text AppM ()
deleteUser = do
currentUserId <- getUserId
userIdParam <- param "id"
targetUserId <- case fromText userIdParam of
Nothing -> raise $ badRequestError "Invalid user ID format"
Just uid -> return uid
-- Only admins can delete users
requireAdmin $ do
-- Don't allow self-deletion
when (currentUserId == targetUserId) $
raise $ badRequestError "Cannot delete your own account"
deleted <- lift $ deleteUserById targetUserId
if deleted
then json $ object ["message" .= ("User deleted successfully" :: Text)]
else raise $ notFoundError "User not found"
-- Create user (admin only)
createUser :: ActionT Text AppM ()
createUser = do
CreateUserRequest{..} <- jsonData
-- Implementation similar to register but allows admin to set roles
json $ object ["message" .= ("User created" :: Text)]
-- Helper functions
getUserId :: ActionT Text AppM UUID
getUserId = do
maybeUserId <- header "X-User-ID"
case maybeUserId of
Nothing -> raise $ internalError "User ID not found in request"
Just uid -> case fromText (toStrict uid) of
Nothing -> raise $ internalError "Invalid user ID format"
Just userId -> return userId
checkIsAdmin :: ActionT Text AppM Bool
checkIsAdmin = do
-- In a real app, check user roles from database
return False
requireAdmin :: ActionT Text AppM () -> ActionT Text AppM ()
requireAdmin action = do
isAdmin <- checkIsAdmin
if isAdmin
then action
else raise $ forbiddenError "Admin access required"
-- Request types
data UpdateUserRequest = UpdateUserRequest
{ updateName :: Maybe Text
, updateEmail :: Maybe Text
} deriving (Show)
instance FromJSON UpdateUserRequest where
parseJSON = withObject "UpdateUserRequest" $ \\v -> UpdateUserRequest
<$> v .:? "name"
<*> v .:? "email"
data CreateUserRequest = CreateUserRequest
{ createEmail :: Text
, createPassword :: Text
, createName :: Text
, createRole :: Maybe Text
} deriving (Show)
instance FromJSON CreateUserRequest where
parseJSON = withObject "CreateUserRequest" $ \\v -> CreateUserRequest
<$> v .: "email"
<*> v .: "password"
<*> v .: "name"
<*> v .:? "role"
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Controllers', 'User.hs'), userContent);
}
async generateMiddleware(projectPath) {
await fs_1.promises.mkdir(path.join(projectPath, 'src', 'Middleware'), { recursive: true });
// Auth middleware
const authMiddlewareContent = `{-# LANGUAGE OverloadedStrings #-}
module Middleware.Auth where
import Data.Text.Lazy (toStrict)
import Network.HTTP.Types.Status
import Network.Wai
import Web.Scotty.Trans
import App
import Services.JWT
-- JWT authentication middleware
authMiddleware :: Text -> Middleware
authMiddleware jwtSecret app req respond = do
let headers = requestHeaders req
authHeader = lookup "Authorization" headers
case authHeader of
Nothing -> app req respond
Just auth -> do
let token = extractToken auth
case token >>= verifyAccessToken jwtSecret of
Left _ -> app req respond
Right claims -> do
-- Add user ID to headers for downstream use
let newHeaders = ("X-User-ID", encodeUtf8 $ toStrict $ jwtUserId claims) : headers
newReq = req { requestHeaders = newHeaders }
app newReq respond
-- Extract token from Authorization header
extractToken :: ByteString -> Maybe Text
extractToken auth =
case B.stripPrefix "Bearer " auth of
Nothing -> Nothing
Just token -> Just $ decodeUtf8 token
-- Require authentication for a route
requireAuth :: ActionT Text AppM () -> ActionT Text AppM ()
requireAuth action = do
maybeUserId <- header "X-User-ID"
case maybeUserId of
Nothing -> do
status status401
json $ object
[ "error" .= ("Unauthorized" :: Text)
, "message" .= ("Authentication required" :: Text)
]
Just _ -> action
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Middleware', 'Auth.hs'), authMiddlewareContent);
// Error middleware
const errorMiddlewareContent = `{-# LANGUAGE OverloadedStrings #-}
module Middleware.Error where
import Control.Exception
import Network.HTTP.Types.Status
import Network.Wai
import Web.Scotty.Trans
import App
-- Error handling middleware
errorHandler :: Middleware
errorHandler app req respond = do
app req respond \`catch\` handleException
where
handleException :: SomeException -> IO ResponseReceived
handleException e = respond $ responseLBS
status500
[("Content-Type", "application/json")]
$ encode $ object
[ "error" .= ("Internal Server Error" :: Text)
, "message" .= ("An unexpected error occurred" :: Text)
, "details" .= show e
]
-- Scotty error handler
scottyErrorHandler :: Text -> ActionT Text AppM ()
scottyErrorHandler err = do
status status500
json $ object
[ "error" .= ("Internal Server Error" :: Text)
, "message" .= err
]
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Middleware', 'Error.hs'), errorMiddlewareContent);
}
async generateModels(projectPath) {
await fs_1.promises.mkdir(path.join(projectPath, 'src', 'Models'), { recursive: true });
// User model
const userModelContent = `{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
module Models.User where
import Data.Aeson
import Data.Time.Clock
import Data.UUID
import GHC.Generics
data User = User
{ userId :: UUID
, userEmail :: Text
, userPasswordHash :: Text
, userName :: Text
, userCreatedAt :: UTCTime
, userUpdatedAt :: UTCTime
} deriving (Show, Eq, Generic)
-- Don't include password hash in JSON
instance ToJSON User where
toJSON user = object
[ "id" .= userId user
, "email" .= userEmail user
, "name" .= userName user
, "createdAt" .= userCreatedAt user
, "updatedAt" .= userUpdatedAt user
]
instance FromJSON User where
parseJSON = withObject "User" $ \\v -> User
<$> v .: "id"
<*> v .: "email"
<*> v .: "passwordHash"
<*> v .: "name"
<*> v .: "createdAt"
<*> v .: "updatedAt"
-- User role
data UserRole = UserRole
{ roleId :: UUID
, roleName :: Text
, rolePermissions :: [Text]
} deriving (Show, Eq, Generic)
instance ToJSON UserRole
instance FromJSON UserRole
-- User session
data UserSession = UserSession
{ sessionId :: UUID
, sessionUserId :: UUID
, sessionToken :: Text
, sessionExpiresAt :: UTCTime
, sessionCreatedAt :: UTCTime
} deriving (Show, Eq, Generic)
instance ToJSON UserSession
instance FromJSON UserSession
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Models', 'User.hs'), userModelContent);
}
async generateDatabase(projectPath) {
const databaseContent = `{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Database where
import Control.Exception (bracket)
import Data.Pool
import Data.Text (pack)
import Database.PostgreSQL.Simple
import Database.PostgreSQL.Simple.SqlQQ
import Config
-- Initialize database connection pool
initDB :: Config -> IO (Pool Connection)
initDB config = createPool
(connect $ postgresConnInfo config)
close
1 -- stripes
60 -- keep alive (seconds)
10 -- max connections
-- Create PostgreSQL connection info
postgresConnInfo :: Config -> ConnectInfo
postgresConnInfo config = ConnectInfo
{ connectHost = configDbHost config
, connectPort = fromIntegral $ configDbPort config
, connectUser = configDbUser config
, connectPassword = configDbPassword config
, connectDatabase = configDbName config
}
-- Run database migrations
runMigrations :: Pool Connection -> IO ()
runMigrations pool = withResource pool $ \\conn -> do
putStrLn "Running database migrations..."
-- Create migrations table
execute_ conn [sql|
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
|]
-- Create users table
execute_ conn [sql|
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
|]
-- Create sessions table
execute_ conn [sql|
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
|]
-- Create roles table
execute_ conn [sql|
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
|]
-- Create user_roles table
execute_ conn [sql|
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id)
)
|]
-- Create indexes
execute_ conn "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)"
execute_ conn "CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)"
execute_ conn "CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)"
putStrLn "Migrations completed successfully"
-- Transaction helper
withTransaction :: Pool Connection -> (Connection -> IO a) -> IO a
withTransaction pool action = withResource pool $ \\conn ->
withTransaction conn (action conn)
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Database.hs'), databaseContent);
}
async generateServices(projectPath) {
await fs_1.promises.mkdir(path.join(projectPath, 'src', 'Services'), { recursive: true });
// JWT service
const jwtServiceContent = `{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Services.JWT where
import Control.Monad (when)
import Data.Aeson
import Data.Text (Text)
import Data.Time.Clock
import Data.Time.Clock.POSIX
import Data.UUID
import Web.JWT
import Config
data JWTClaims = JWTClaims
{ jwtUserId :: UUID
, jwtEmail :: Text
, jwtExp :: UTCTime
} deriving (Show, Eq)
-- Generate access token (short-lived)
generateAccessToken :: Config -> UUID -> IO Text
generateAccessToken config userId = do
now <- getCurrentTime
let expTime = addUTCTime (15 * 60) now -- 15 minutes
claims = JWTClaims userId "" expTime
return $ createToken config claims "access"
-- Generate refresh token (long-lived)
generateRefreshToken :: Config -> UUID -> IO Text
generateRefreshToken config userId = do
now <- getCurrentTime
let expTime = addUTCTime (7 * 24 * 60 * 60) now -- 7 days
claims = JWTClaims userId "" expTime
return $ createToken config claims "refresh"
-- Create JWT token
createToken :: Config -> JWTClaims -> Text -> Text
createToken config JWTClaims{..} tokenType =
let cs = mempty
{ iss = stringOrURI $ configJwtIssuer config
, sub = stringOrURI $ toText jwtUserId
, aud = Left <$> stringOrURI "api"
, Web.JWT.exp = numericDate $ utcTimeToPOSIXSeconds jwtExp
, iat = numericDate $ utcTimeToPOSIXSeconds $ addUTCTime (-60) jwtExp
, unregisteredClaims = ClaimsMap $ fromList
[ ("type", String tokenType)
, ("userId", String $ toText jwtUserId)
]
}
key = hmacSecret $ configJwtSecret config
in encodeSigned key mempty cs
-- Verify access token
verifyAccessToken :: Text -> Text -> Either Text JWTClaims
verifyAccessToken secret token = verifyToken secret token "access"
-- Verify refresh token
verifyRefreshToken :: Text -> Text -> Either Text JWTClaims
verifyRefreshToken secret token = verifyToken secret token "refresh"
-- Generic token verification
verifyToken :: Text -> Text -> Text -> Either Text JWTClaims
verifyToken secret token expectedType = do
let key = hmacSecret secret
unverified <- case decode token of
Nothing -> Left "Invalid token format"
Just t -> Right t
verified <- case verify key unverified of
Nothing -> Left "Token signature verification failed"
Just t -> Right t
let cs = claims verified
ClaimsMap customClaims = unregisteredClaims cs
-- Check token type
case lookup "type" customClaims of
Just (String t) | t == expectedType -> return ()
_ -> Left "Invalid token type"
-- Check expiration
now <- getCurrentTime
case Web.JWT.exp cs of
Nothing -> Left "Token missing expiration"
Just expTime -> do
let expUTC = posixSecondsToUTCTime $ secondsToNominalDiffTime $ fromInteger $ fromNumericDate expTime
when (now > expUTC) $ Left "Token expired"
-- Extract user ID
userId <- case lookup "userId" customClaims of
Just (String uid) -> case fromText uid of
Nothing -> Left "Invalid user ID in token"
Just u -> Right u
_ -> Left "User ID missing from token"
-- Get expiration time
expTime <- case Web.JWT.exp cs of
Nothing -> Left "Expiration time missing"
Just e -> Right $ posixSecondsToUTCTime $ secondsToNominalDiffTime $ fromInteger $ fromNumericDate e
Right $ JWTClaims userId "" expTime
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Services', 'JWT.hs'), jwtServiceContent);
// User service
const userServiceContent = `{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Services.User where
import Control.Monad (forM)
import Data.UUID
import Database.PostgreSQL.Simple
import Database.PostgreSQL.Simple.SqlQQ
import App
import Models.User
-- Get user by ID
getUserById :: UUID -> AppM (Maybe User)
getUserById uid = withDB $ \\conn -> do
rows <- query conn [sql|
SELECT id, email, password_hash, name, created_at, updated_at
FROM users
WHERE id = ?
|] (Only uid)
case rows of
[] -> return Nothing
[(id, email, hash, name, created, updated)] ->
return $ Just User
{ userId = id
, userEmail = email
, userPasswordHash = hash
, userName = name
, userCreatedAt = created
, userUpdatedAt = updated
}
_ -> error "Multiple users with same ID"
-- Get user by email
getUserByEmail :: Text -> AppM (Maybe User)
getUserByEmail email = withDB $ \\conn -> do
rows <- query conn [sql|
SELECT id, email, password_hash, name, created_at, updated_at
FROM users
WHERE email = ?
|] (Only email)
case rows of
[] -> return Nothing
[(id, em, hash, name, created, updated)] ->
return $ Just User
{ userId = id
, userEmail = em
, userPasswordHash = hash
, userName = name
, userCreatedAt = created
, userUpdatedAt = updated
}
_ -> error "Multiple users with same email"
-- Create new user
createUser :: User -> AppM ()
createUser user = withDB $ \\conn -> do
execute conn [sql|
INSERT INTO users (id, email, password_hash, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
|] ( userId user
, userEmail user
, userPasswordHash user
, userName user
, userCreatedAt user
, userUpdatedAt user
)
return ()
-- Get users with pagination
getUsers :: Int -> Int -> AppM [User]
getUsers limit offset = withDB $ \\conn -> do
rows <- query conn [sql|
SELECT id, email, password_hash, name, created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
|] (limit, offset)
forM rows $ \\(id, email, hash, name, created, updated) ->
return User
{ userId = id
, userEmail = email
, userPasswordHash = hash
, userName = name
, userCreatedAt = created
, userUpdatedAt = updated
}
-- Get total user count
getUserCount :: AppM Int
getUserCount = withDB $ \\conn -> do
[Only count] <- query_ conn "SELECT COUNT(*) FROM users"
return count
-- Update user details
updateUserDetails :: UUID -> Maybe Text -> Maybe Text -> AppM (Maybe User)
updateUserDetails uid maybeName maybeEmail = withDB $ \\conn -> do
-- Build dynamic update query
let updates = catMaybes
[ ("name = ?" ,) . Only <$> maybeName
, ("email = ?" ,) . Only <$> maybeEmail
]
if null updates
then getUserById uid
else do
-- Execute update
execute conn (Query $ "UPDATE users SET " <> intercalate ", " (map fst updates) <> ", updated_at = CURRENT_TIMESTAMP WHERE id = ?")
(map snd updates ++ [Only uid])
getUserById uid
-- Delete user
deleteUserById :: UUID -> AppM Bool
deleteUserById uid = withDB $ \\conn -> do
count <- execute conn "DELETE FROM users WHERE id = ?" (Only uid)
return $ count > 0
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Services', 'User.hs'), userServiceContent);
}
async generateConfig(projectPath, options) {
const configContent = `{-# LANGUAGE OverloadedStrings #-}
module Config where
import Data.Configurator
import Data.Text (Text)
import qualified Data.Text as T
data Environment = Development | Staging | Production
deriving (Show, Eq)
data Config = Config
{ configEnv :: Environment
, configPort :: Int
, configHost :: Text
, configDbHost :: String
, configDbPort :: Int
, configDbUser :: String
, configDbPassword :: String
, configDbName :: String
, configJwtSecret :: Text
, configJwtIssuer :: Text
, configLogLevel :: LogLevel
, configCorsOrigin :: Text
} deriving (Show)
loadConfig :: FilePath -> IO Config
loadConfig path = do
cfg <- load [Required path]
env <- lookupDefault "development" cfg "environment" :: IO String
let environment = case env of
"production" -> Production
"staging" -> Staging
_ -> Development
Config
<$> pure environment
<*> lookupDefault 3000 cfg "server.port"
<*> lookupDefault "0.0.0.0" cfg "server.host"
<*> lookupDefault "localhost" cfg "database.host"
<*> lookupDefault 5432 cfg "database.port"
<*> lookupDefault "postgres" cfg "database.user"
<*> lookupDefault "postgres" cfg "database.password"
<*> lookupDefault "${options.name}" cfg "database.name"
<*> lookupDefault "your-256-bit-secret" cfg "jwt.secret"
<*> lookupDefault "${options.name}-api" cfg "jwt.issuer"
<*> (parseLogLevel <$> lookupDefault "info" cfg "log.level")
<*> lookupDefault "*" cfg "cors.origin"
parseLogLevel :: String -> LogLevel
parseLogLevel "debug" = Debug
parseLogLevel "info" = Info
parseLogLevel "warning" = Warning
parseLogLevel "error" = Error
parseLogLevel _ = Info
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Config.hs'), configContent);
// Create config file
await fs_1.promises.mkdir(path.join(projectPath, 'config'), { recursive: true });
const appConfContent = `# Scotty Application Configuration
environment = "development"
[server]
port = 3000
host = "0.0.0.0"
[database]
host = "localhost"
port = 5432
user = "postgres"
password = "postgres"
name = "${options.name}"
[jwt]
secret = "your-256-bit-secret-change-in-production"
issuer = "${options.name}-api"
[log]
level = "info"
[cors]
origin = "*"
`;
await fs_1.promises.writeFile(path.join(projectPath, 'config', 'app.conf'), appConfContent);
}
async generateUtilities(projectPath) {
await fs_1.promises.mkdir(path.join(projectPath, 'src', 'Utils'), { recursive: true });
// Validation utilities
const validationContent = `{-# LANGUAGE OverloadedStrings #-}
module Utils.Validation where
import Data.Text (Text)
import qualified Data.Text as T
import Text.Regex.TDFA
-- Email validation
isValidEmail :: Text -> Bool
isValidEmail email =
T.unpack email =~ ("^[a-zA-Z0-9+._-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$" :: String)
-- Password validation
isValidPassword :: Text -> Bool
isValidPassword password =
T.length password >= 8 &&
any isUpper (T.unpack password) &&
any isLower (T.unpack password) &&
any isDigit (T.unpack password)
-- Username validation
isValidUsername :: Text -> Bool
isValidUsername username =
T.length username >= 3 &&
T.length username <= 30 &&
T.all isAlphaNum username
-- UUID validation
isValidUUID :: Text -> Bool
isValidUUID uuid =
T.unpack uuid =~ ("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" :: String)
-- Sanitize input
sanitizeInput :: Text -> Text
sanitizeInput = T.strip . T.filter (\\c -> c /= '<' && c /= '>' && c /= '&')
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Utils', 'Validation.hs'), validationContent);
// Response utilities
const responseContent = `{-# LANGUAGE OverloadedStrings #-}
module Utils.Response where
import Data.Aeson
import Network.HTTP.Types.Status
import Web.Scotty.Trans
import App
-- Success response helpers
success :: ToJSON a => a -> ActionT Text AppM ()
success = json
successWithMessage :: Text -> ActionT Text AppM ()
successWithMessage msg = json $ object ["message" .= msg]
created :: ToJSON a => a -> ActionT Text AppM ()
created resource = do
status status201
json resource
noContent :: ActionT Text AppM ()
noContent = status status204
-- Error response helpers
errorResponse :: Status -> Text -> Maybe Value -> ActionT Text AppM ()
errorResponse s msg details = do
status s
json $ object $
[ "error" .= True
, "message" .= msg
] ++ maybe [] (\\d -> ["details" .= d]) details
badRequest :: Text -> ActionT Text AppM ()
badRequest = errorResponse status400
unauthorized :: Text -> ActionT Text AppM ()
unauthorized = errorResponse status401
forbidden :: Text -> ActionT Text AppM ()
forbidden = errorResponse status403
notFound :: Text -> ActionT Text AppM ()
notFound = errorResponse status404
conflict :: Text -> ActionT Text AppM ()
conflict = errorResponse status409
internalServerError :: Text -> ActionT Text AppM ()
internalServerError = errorResponse status500
-- Pagination helpers
data PaginatedResponse a = PaginatedResponse
{ items :: [a]
, page :: Int
, pageSize :: Int
, totalItems :: Int
, totalPages :: Int
} deriving (Show)
instance ToJSON a => ToJSON (PaginatedResponse a) where
toJSON pr = object
[ "items" .= items pr
, "pagination" .= object
[ "page" .= page pr
, "pageSize" .= pageSize pr
, "totalItems" .= totalItems pr
, "totalPages" .= totalPages pr
, "hasNext" .= (page pr < totalPages pr)
, "hasPrev" .= (page pr > 1)
]
]
paginatedResponse :: ToJSON a => [a] -> Int -> Int -> Int -> ActionT Text AppM ()
paginatedResponse items currentPage size total =
json $ PaginatedResponse items currentPage size total $
ceiling (fromIntegral total / fromIntegral size :: Double)
`;
await fs_1.promises.writeFile(path.join(projectPath, 'src', 'Utils', 'Response.hs'), responseContent);
}
async generateHealthCheck(projectPath) {
// Health check is implemented in Controllers.Health
}
async generateAPIDocs(projectPath) {
const apiDocsContent = `# ${this.config.framework} API Documentation
## Overview
This is a RESTful API built with Scotty framework in Haskell.
## Authentication
The API uses JWT (JSON Web Tokens) for authentication. Include the token in the Authorization header:
\`\`\`
Authorization: Bearer <your-jwt-token>
\`\`\`
## Endpoints
### Health Check
\`\`\`http
GET /health
GET /api/v1/health
\`\`\`
Returns the health status of the API.
**Response:**
\`\`\`json
{
"status": "healthy",
"timestamp": "2024-01-01T00:00:00Z",
"version": "1.0.0",
"services": {
"database": "up"
}
}
\`\`\`
### Authentication
#### Register
\`\`\`http
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123",
"name": "John Doe"
}
\`\`\`
#### Login
\`\`\`http
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123"
}
\`\`\`
#### Refresh Token
\`\`\`http
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refreshToken": "<refresh-token>"
}
\`\`\`
#### Get Current User
\`\`\`http
GET /api/v1/auth/me
Authorization: Bearer <access-token>
\`\`\`
#### Logout
\`\`\`http
POST /api/v1/auth/logout
Authorization: Bearer <access-token>
\`\`\`
### Users
#### List Users
\`\`\`http
GET /api/v1/users?page=1&limit=10
Authorization: Bearer <access-token>
\`\`\`
#### Get User
\`\`\`http
GET /api/v1/users/:id
Authorization: Bearer <access-token>
\`\`\`
#### Update User
\`\`\`http
PUT /api/v1/users/:id
Authorization: Bearer <access-token>
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com"
}
\`\`\`
#### Delete User (Admin Only)
\`\`\`http
DELETE /api/v1/users/:id
Authorization: Bearer <access-token>
\`\`\`
#### Create User (Admin Only)
\`\`\`http
POST /api/v1/admin/users
Authorization: Bearer <access-token>
Content-Type: application/json
{
"email": "newuser@example.com",
"password": "TempPass123",
"name": "New User",
"role": "user"
}
\`\`\`
## Error Responses
All error responses follow this format:
\`\`\`json
{
"error": true,
"message": "Error description",
"details": {} // Optional additional information
}
\`\`\`
### Common HTTP Status Codes
- \`200\` - Success
- \`201\` - Created
- \`204\` - No Content
- \`400\` - Bad Request
- \`401\` - Unauthorized
- \`403\` - Forbidden
- \`404\` - Not Found
- \`409\` - Conflict
- \`500\` - Internal Server Error
## Pagination
Paginated endpoints return data in this format:
\`\`\`json
{
"items": [...],
"pagination": {
"page": 1,
"pageSize": 10,
"totalItems": 100,
"totalPages": 10,
"hasNext": true,
"hasPrev": false
}
}
\`\`\`
`;
await fs_1.promises.writeFile(path.join(projectPath, 'docs', 'API.md'), apiDocsContent);
}
}
exports.ScottyGenerator = ScottyGenerator;