From f4516aab6d93d9dc0a603fcf1fc6cbb7a7879954 Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Wed, 15 Oct 2025 11:08:22 +0200 Subject: [PATCH] :ambulance: add PHP vanilla boilerplate --- docs/BOILERPLATE.md | 112 + src/boilerplate/mod.rs | 7 + src/boilerplate/php.rs | 170 ++ src/boilerplate/templates.rs | 1859 +++++++++++++++++ src/cli/args.rs | 17 + src/cli/commands.rs | 35 +- tests/integration/boilerplate/mod.rs | 7 + .../integration/boilerplate/vanilla_tests.rs | 324 +++ 8 files changed, 2530 insertions(+), 1 deletion(-) create mode 100644 tests/integration/boilerplate/vanilla_tests.rs diff --git a/docs/BOILERPLATE.md b/docs/BOILERPLATE.md index 01b6b56..61076ab 100644 --- a/docs/BOILERPLATE.md +++ b/docs/BOILERPLATE.md @@ -304,4 +304,116 @@ docker-compose exec app php bin/console doctrine:migrations:migrate curl http://localhost/api/health ``` +## PHP Vanilla Projects (Clean Architecture) +```bash +# PHP Vanilla + PostgreSQL (default) +athena init vanilla my-api + +# PHP Vanilla + MySQL +athena init vanilla my-api --with-mysql + +# Without Docker files +athena init vanilla my-api --no-docker +``` + +**Generated PHP Vanilla Structure:** +``` +my-api/ +├── src/ +│ ├── Domain/ # Domain layer (Clean Architecture) +│ │ └── User/ +│ │ ├── Entity/ # Domain entities +│ │ │ └── User.php # Pure domain entity with business logic +│ │ ├── Repository/ # Repository interfaces +│ │ │ └── UserRepositoryInterface.php +│ │ ├── Service/ # Domain services +│ │ │ └── UserService.php # User business logic +│ │ └── ValueObject/ # Value objects +│ │ ├── UserId.php # UUID-based user ID +│ │ └── Email.php # Email validation value object +│ ├── Application/ # Application layer +│ │ ├── User/ +│ │ │ ├── Command/ # Command objects +│ │ │ │ └── CreateUserCommand.php +│ │ │ └── Handler/ # Command handlers +│ │ │ └── CreateUserHandler.php +│ │ └── Auth/ +│ │ ├── Command/ # Authentication commands +│ │ │ └── LoginCommand.php +│ │ └── Handler/ # Authentication handlers +│ │ └── LoginHandler.php +│ └── Infrastructure/ # Infrastructure layer +│ ├── Http/ +│ │ ├── Router.php # Custom routing system +│ │ ├── Request.php # HTTP request abstraction +│ │ ├── Response.php # HTTP response abstraction +│ │ ├── Controller/ +│ │ │ └── Api/V1/ # Versioned API controllers +│ │ │ ├── AuthController.php # JWT authentication +│ │ │ └── UserController.php # User management +│ │ └── Middleware/ # HTTP middleware +│ │ ├── AuthMiddleware.php # JWT validation +│ │ └── CorsMiddleware.php # CORS handling +│ ├── Persistence/ +│ │ └── PDO/ # PDO implementations +│ │ └── UserRepository.php # User data access +│ ├── Database/ +│ │ └── PDOConnection.php # Database connection management +│ ├── Security/ +│ │ └── JWTManager.php # JWT token management +│ └── Config/ +│ └── AppConfig.php # Configuration management +├── public/ +│ ├── index.php # Application entry point +│ └── .htaccess # Apache rewrite rules +├── config/ +│ ├── app.php # Application configuration +│ └── database.php # Database configuration +├── database/ +│ └── migrations/ +│ └── 001_create_users_table.sql # Database schema +├── tests/ # Comprehensive test suite +│ ├── Unit/ # Unit tests +│ │ └── UserTest.php # Domain entity tests +│ ├── Integration/ # Integration tests +│ └── Functional/ # Functional tests +│ └── AuthTest.php # API endpoint tests +├── docker/ # Docker configurations +├── composer.json # PHP dependencies (PHP 8.2+) +├── phpunit.xml # Testing configuration +├── Dockerfile # Multi-stage production build +├── docker-compose.yml # Full stack deployment +├── .env.example # Environment template +└── README.md # Setup and API documentation +``` + +**PHP Vanilla Architecture Features:** +- **Pure Clean Architecture**: Domain-driven design without framework constraints +- **Custom HTTP Layer**: Built-in Router, Request/Response handling +- **PDO Database Layer**: Multi-database support (PostgreSQL, MySQL) +- **JWT Authentication**: Secure token-based authentication +- **Value Objects**: Type-safe domain modeling +- **Command/Handler Pattern**: CQRS-lite for business operations +- **PSR-4 Autoloading**: Modern PHP namespace organization +- **Dependency Injection**: Manual DI for learning and control + +**Database Support:** +- **PostgreSQL** (default): Production-ready with UUID support +- **MySQL**: Alternative with charset configuration +- **PDO Abstraction**: Database-agnostic query layer +- **Migration System**: SQL-based schema management +- **Connection Pooling**: Singleton pattern for efficiency + +**API Endpoints:** +``` +GET /api/v1/health # Health check +POST /api/v1/auth/register # User registration +POST /api/v1/auth/login # JWT authentication +POST /api/v1/auth/logout # Token invalidation +GET /api/v1/auth/me # Current user info +GET /api/v1/users # List users +GET /api/v1/users/{id} # Get user by ID +POST /api/v1/users # Create user +``` + All generated projects include comprehensive README files with setup instructions, API documentation, and deployment guides. diff --git a/src/boilerplate/mod.rs b/src/boilerplate/mod.rs index d021f6b..89aeac4 100644 --- a/src/boilerplate/mod.rs +++ b/src/boilerplate/mod.rs @@ -84,6 +84,13 @@ pub fn generate_symfony_project(config: &ProjectConfig) -> BoilerplateResult<()> generator.generate_symfony_project(config) } +/// Generate a PHP Vanilla boilerplate project +pub fn generate_vanilla_project(config: &ProjectConfig) -> BoilerplateResult<()> { + let generator = php::PhpGenerator::new(); + generator.validate_config(config)?; + generator.generate_vanilla_project(config) +} + /// Validate project name pub fn validate_project_name(name: &str) -> BoilerplateResult<()> { if name.is_empty() { diff --git a/src/boilerplate/php.rs b/src/boilerplate/php.rs index 490dc07..0c31353 100644 --- a/src/boilerplate/php.rs +++ b/src/boilerplate/php.rs @@ -34,6 +34,7 @@ pub struct PhpGenerator; pub enum PhpFramework { Laravel, Symfony, + Vanilla, } impl Default for PhpGenerator { @@ -369,6 +370,7 @@ impl PhpGenerator { let docker_compose = match framework { PhpFramework::Laravel => replace_template_vars_string(LARAVEL_DOCKER_COMPOSE, &vars), PhpFramework::Symfony => replace_template_vars_string(SYMFONY_DOCKER_COMPOSE, &vars), + PhpFramework::Vanilla => replace_template_vars_string(SYMFONY_DOCKER_COMPOSE, &vars), // Reuse Symfony's Docker setup }; write_file(base_path.join("docker-compose.yml"), &docker_compose)?; @@ -383,6 +385,11 @@ impl PhpGenerator { let env_example = match framework { PhpFramework::Laravel => replace_template_vars_string(LARAVEL_ENV_EXAMPLE, &vars), PhpFramework::Symfony => replace_template_vars_string(SYMFONY_ENV_EXAMPLE, &vars), + PhpFramework::Vanilla => { + // For Vanilla, the .env.example is already generated in generate_vanilla_files + // So we skip it here to avoid overwriting + return Ok(()); + } }; write_file(base_path.join(".env.example"), &env_example)?; @@ -393,6 +400,7 @@ impl PhpGenerator { let nginx_default_conf = match framework { PhpFramework::Laravel => replace_template_vars_string(LARAVEL_NGINX_DEFAULT_CONF, &vars), PhpFramework::Symfony => replace_template_vars_string(SYMFONY_NGINX_DEFAULT_CONF, &vars), + PhpFramework::Vanilla => replace_template_vars_string(SYMFONY_NGINX_DEFAULT_CONF, &vars), // Reuse Symfony's nginx config }; write_file(base_path.join("docker/nginx/default.conf"), &nginx_default_conf)?; @@ -427,6 +435,10 @@ impl PhpGenerator { let unit_test = replace_template_vars_string(SYMFONY_USER_UNIT_TEST, &vars); write_file(base_path.join("tests/Unit/Domain/UserTest.php"), &unit_test)?; } + PhpFramework::Vanilla => { + // Test files are already generated in generate_vanilla_files + // This is to avoid duplication since Vanilla handles its own test generation + } } Ok(()) @@ -438,6 +450,7 @@ impl PhpGenerator { let readme = match framework { PhpFramework::Laravel => replace_template_vars_string(LARAVEL_README, &vars), PhpFramework::Symfony => replace_template_vars_string(SYMFONY_README, &vars), + PhpFramework::Vanilla => replace_template_vars_string(VANILLA_README, &vars), }; write_file(base_path.join("README.md"), &readme)?; @@ -545,6 +558,163 @@ impl PhpGenerator { Ok(()) } + + pub fn generate_vanilla_project(&self, config: &ProjectConfig) -> BoilerplateResult<()> { + let base_path = Path::new(&config.directory); + let framework = PhpFramework::Vanilla; + + println!("Generating PHP Vanilla project with Clean Architecture: {}", config.name); + + // Create directory structure + println!(" 📁 Creating clean architecture structure..."); + self.create_vanilla_structure(base_path)?; + + // Generate PHP Vanilla files + println!(" 🐘 Generating PHP vanilla application files..."); + self.generate_vanilla_files(config, base_path)?; + + // Generate Docker files + if config.include_docker { + println!(" 🐳 Generating Docker configuration..."); + self.generate_docker_files(config, base_path, &framework)?; + } + + // Generate test files + println!(" 🧪 Creating test suite..."); + self.generate_test_files(config, base_path, &framework)?; + + // Generate documentation + println!(" 📚 Generating documentation..."); + self.generate_documentation(config, base_path, &framework)?; + + println!("PHP Vanilla project '{}' created successfully!", config.name); + println!("📍 Location: {}", base_path.display()); + + if config.include_docker { + println!("\n🔧 Next steps:"); + println!(" cd {}", config.directory); + println!(" cp .env.example .env # Edit with your configuration"); + println!(" docker-compose up --build"); + println!(" docker-compose exec app composer install"); + } else { + println!("\n🔧 Next steps:"); + println!(" cd {}", config.directory); + println!(" composer install"); + println!(" cp .env.example .env # Edit with your configuration"); + println!(" php -S localhost:8000 public/index.php"); + } + + Ok(()) + } + + fn create_vanilla_structure(&self, base_path: &Path) -> BoilerplateResult<()> { + let directories = vec![ + // Clean Architecture structure + "src", + "src/Domain", + "src/Domain/User", + "src/Domain/User/Entity", + "src/Domain/User/Repository", + "src/Domain/User/Service", + "src/Domain/User/ValueObject", + "src/Domain/Auth", + "src/Domain/Auth/Service", + "src/Application", + "src/Application/User", + "src/Application/User/Command", + "src/Application/User/Query", + "src/Application/User/Handler", + "src/Application/Auth", + "src/Application/Auth/Command", + "src/Application/Auth/Handler", + "src/Infrastructure", + "src/Infrastructure/Http", + "src/Infrastructure/Http/Controller", + "src/Infrastructure/Http/Controller/Api", + "src/Infrastructure/Http/Controller/Api/V1", + "src/Infrastructure/Http/Middleware", + "src/Infrastructure/Persistence", + "src/Infrastructure/Persistence/PDO", + "src/Infrastructure/Security", + "src/Infrastructure/Routing", + "src/Infrastructure/Database", + "src/Infrastructure/Config", + // Public directory + "public", + // Config directory + "config", + // Database directory + "database", + "database/migrations", + // Tests + "tests", + "tests/Unit", + "tests/Integration", + "tests/Functional", + // Docker if needed + "docker", + "docker/php", + "docker/nginx", + ]; + + create_directory_structure(base_path, &directories) + } + + fn generate_vanilla_files(&self, config: &ProjectConfig, base_path: &Path) -> BoilerplateResult<()> { + let vars = self.get_template_vars(config); + + // Core application files + write_file(base_path.join("composer.json"), &replace_template_vars_string(VANILLA_COMPOSER_JSON, &vars))?; + write_file(base_path.join("public/index.php"), &replace_template_vars_string(VANILLA_INDEX_PHP, &vars))?; + write_file(base_path.join("public/.htaccess"), &replace_template_vars_string(VANILLA_HTACCESS, &vars))?; + + // Configuration files + write_file(base_path.join("config/database.php"), &replace_template_vars_string(VANILLA_DATABASE_CONFIG, &vars))?; + write_file(base_path.join("config/app.php"), &replace_template_vars_string(VANILLA_APP_CONFIG, &vars))?; + + // Environment files + write_file(base_path.join(".env.example"), &replace_template_vars_string(VANILLA_ENV_EXAMPLE, &vars))?; + + // Core framework files + write_file(base_path.join("src/Infrastructure/Http/Router.php"), &replace_template_vars_string(VANILLA_ROUTER, &vars))?; + write_file(base_path.join("src/Infrastructure/Http/Request.php"), &replace_template_vars_string(VANILLA_REQUEST, &vars))?; + write_file(base_path.join("src/Infrastructure/Http/Response.php"), &replace_template_vars_string(VANILLA_RESPONSE, &vars))?; + write_file(base_path.join("src/Infrastructure/Database/PDOConnection.php"), &replace_template_vars_string(VANILLA_PDO_CONNECTION, &vars))?; + write_file(base_path.join("src/Infrastructure/Security/JWTManager.php"), &replace_template_vars_string(VANILLA_JWT_MANAGER, &vars))?; + write_file(base_path.join("src/Infrastructure/Config/AppConfig.php"), &replace_template_vars_string(VANILLA_APP_CONFIG_CLASS, &vars))?; + + // Domain layer + write_file(base_path.join("src/Domain/User/Entity/User.php"), &replace_template_vars_string(VANILLA_USER_ENTITY, &vars))?; + write_file(base_path.join("src/Domain/User/Repository/UserRepositoryInterface.php"), &replace_template_vars_string(VANILLA_USER_REPOSITORY_INTERFACE, &vars))?; + write_file(base_path.join("src/Domain/User/Service/UserService.php"), &replace_template_vars_string(VANILLA_USER_SERVICE, &vars))?; + write_file(base_path.join("src/Domain/User/ValueObject/Email.php"), &replace_template_vars_string(VANILLA_EMAIL_VALUE_OBJECT, &vars))?; + write_file(base_path.join("src/Domain/User/ValueObject/UserId.php"), &replace_template_vars_string(VANILLA_USER_ID_VALUE_OBJECT, &vars))?; + + // Application layer + write_file(base_path.join("src/Application/User/Command/CreateUserCommand.php"), &replace_template_vars_string(VANILLA_CREATE_USER_COMMAND, &vars))?; + write_file(base_path.join("src/Application/User/Handler/CreateUserHandler.php"), &replace_template_vars_string(VANILLA_CREATE_USER_HANDLER, &vars))?; + write_file(base_path.join("src/Application/Auth/Command/LoginCommand.php"), &replace_template_vars_string(VANILLA_LOGIN_COMMAND, &vars))?; + write_file(base_path.join("src/Application/Auth/Handler/LoginHandler.php"), &replace_template_vars_string(VANILLA_LOGIN_HANDLER, &vars))?; + + // Infrastructure layer + write_file(base_path.join("src/Infrastructure/Http/Controller/Api/V1/AuthController.php"), &replace_template_vars_string(VANILLA_AUTH_CONTROLLER, &vars))?; + write_file(base_path.join("src/Infrastructure/Http/Controller/Api/V1/UserController.php"), &replace_template_vars_string(VANILLA_USER_CONTROLLER, &vars))?; + write_file(base_path.join("src/Infrastructure/Persistence/PDO/UserRepository.php"), &replace_template_vars_string(VANILLA_USER_REPOSITORY, &vars))?; + write_file(base_path.join("src/Infrastructure/Http/Middleware/AuthMiddleware.php"), &replace_template_vars_string(VANILLA_AUTH_MIDDLEWARE, &vars))?; + write_file(base_path.join("src/Infrastructure/Http/Middleware/CorsMiddleware.php"), &replace_template_vars_string(VANILLA_CORS_MIDDLEWARE, &vars))?; + + // Database migrations + write_file(base_path.join("database/migrations/001_create_users_table.sql"), &replace_template_vars_string(VANILLA_USERS_MIGRATION, &vars))?; + + // Testing configuration + write_file(base_path.join("phpunit.xml"), &replace_template_vars_string(VANILLA_PHPUNIT_XML, &vars))?; + + // Test files + write_file(base_path.join("tests/Unit/UserTest.php"), &replace_template_vars_string(VANILLA_USER_TEST, &vars))?; + write_file(base_path.join("tests/Functional/AuthTest.php"), &replace_template_vars_string(VANILLA_AUTH_FUNCTIONAL_TEST, &vars))?; + + Ok(()) + } } impl BoilerplateGenerator for PhpGenerator { diff --git a/src/boilerplate/templates.rs b/src/boilerplate/templates.rs index 720873b..2cf4671 100644 --- a/src/boilerplate/templates.rs +++ b/src/boilerplate/templates.rs @@ -5659,4 +5659,1863 @@ networks: pub const SYMFONY_AUTH_FUNCTIONAL_TEST: &str = r#"load(); +} + +// Error handling +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +// Initialize configuration +AppConfig::load(); + +// Create request object +$request = Request::createFromGlobals(); + +// Apply CORS middleware +$corsMiddleware = new CorsMiddleware(); +$corsMiddleware->handle($request); + +// Initialize router +$router = new Router(); + +// Define API routes +$router->get('/api/v1/health', function() { + return new Response(['status' => 'OK', 'timestamp' => time()], 200); +}); + +// User routes +$router->post('/api/v1/auth/register', 'App\Infrastructure\Http\Controller\Api\V1\AuthController@register'); +$router->post('/api/v1/auth/login', 'App\Infrastructure\Http\Controller\Api\V1\AuthController@login'); +$router->post('/api/v1/auth/logout', 'App\Infrastructure\Http\Controller\Api\V1\AuthController@logout'); +$router->get('/api/v1/auth/me', 'App\Infrastructure\Http\Controller\Api\V1\AuthController@me'); + +$router->get('/api/v1/users', 'App\Infrastructure\Http\Controller\Api\V1\UserController@index'); +$router->get('/api/v1/users/{id}', 'App\Infrastructure\Http\Controller\Api\V1\UserController@show'); +$router->post('/api/v1/users', 'App\Infrastructure\Http\Controller\Api\V1\UserController@store'); + +// Handle the request +try { + $response = $router->handle($request); + $response->send(); +} catch (Exception $e) { + $errorResponse = new Response([ + 'error' => $e->getMessage(), + 'code' => $e->getCode() ?: 500 + ], 500); + $errorResponse->send(); +} +"#; + + pub const VANILLA_HTACCESS: &str = r#"RewriteEngine On + +# Handle Angular and React routing, send all requests to index.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] + +# Security headers +Header always set X-Content-Type-Options nosniff +Header always set X-Frame-Options DENY +Header always set X-XSS-Protection "1; mode=block" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Content-Security-Policy "default-src 'self'" + +# CORS headers +Header always set Access-Control-Allow-Origin "*" +Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +Header always set Access-Control-Allow-Headers "Content-Type, Authorization" + +# Handle preflight requests +RewriteCond %{REQUEST_METHOD} OPTIONS +RewriteRule ^(.*)$ index.php [QSA,L] +"#; + + pub const VANILLA_DATABASE_CONFIG: &str = r#" 'pgsql', + + 'connections' => [ + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => $_ENV['DB_HOST'] ?? 'localhost', + 'port' => $_ENV['DB_PORT'] ?? '5432', + 'database' => $_ENV['DB_DATABASE'] ?? '{{snake_case}}_db', + 'username' => $_ENV['DB_USERNAME'] ?? 'postgres', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + 'sslmode' => 'prefer', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'host' => $_ENV['DB_HOST'] ?? 'localhost', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? '{{snake_case}}_db', + 'username' => $_ENV['DB_USERNAME'] ?? 'root', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + ], + ], +]; +"#; + + pub const VANILLA_APP_CONFIG: &str = r#" $_ENV['APP_NAME'] ?? '{{project_name}}', + 'env' => $_ENV['APP_ENV'] ?? 'production', + 'debug' => filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'url' => $_ENV['APP_URL'] ?? 'http://localhost:8000', + 'timezone' => $_ENV['APP_TIMEZONE'] ?? 'UTC', + + 'jwt' => [ + 'secret' => $_ENV['JWT_SECRET'] ?? '', + 'ttl' => (int) ($_ENV['JWT_TTL'] ?? 3600), // 1 hour + 'algorithm' => 'HS256', + ], + + 'cors' => [ + 'allowed_origins' => explode(',', $_ENV['CORS_ALLOWED_ORIGINS'] ?? '*'), + 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'], + 'expose_headers' => [], + 'max_age' => 86400, + 'supports_credentials' => false, + ], +]; +"#; + + pub const VANILLA_ENV_EXAMPLE: &str = r#"# Application Configuration +APP_NAME={{project_name}} +APP_ENV=production +APP_DEBUG=false +APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC + +# Database Configuration +DB_CONNECTION=pgsql +DB_HOST=postgres +DB_PORT=5432 +DB_DATABASE={{snake_case}}_db +DB_USERNAME=postgres +DB_PASSWORD=your-secure-password + +# JWT Configuration +JWT_SECRET=your-jwt-secret-key +JWT_TTL=3600 + +# CORS Configuration +CORS_ALLOWED_ORIGINS=* + +# Security +BCRYPT_ROUNDS=12 +"#; + + pub const VANILLA_ROUTER: &str = r#"addRoute('GET', $path, $handler); + } + + public function post(string $path, $handler): void + { + $this->addRoute('POST', $path, $handler); + } + + public function put(string $path, $handler): void + { + $this->addRoute('PUT', $path, $handler); + } + + public function delete(string $path, $handler): void + { + $this->addRoute('DELETE', $path, $handler); + } + + public function options(string $path, $handler): void + { + $this->addRoute('OPTIONS', $path, $handler); + } + + public function middleware(string $middleware): self + { + $this->middlewares[] = $middleware; + return $this; + } + + private function addRoute(string $method, string $path, $handler): void + { + $this->routes[] = [ + 'method' => $method, + 'path' => $path, + 'handler' => $handler, + 'middlewares' => $this->middlewares + ]; + $this->middlewares = []; // Reset middlewares for next route + } + + public function handle(Request $request): Response + { + $method = $request->getMethod(); + $path = $request->getPath(); + + foreach ($this->routes as $route) { + if ($route['method'] === $method && $this->matchPath($route['path'], $path)) { + // Apply middlewares + foreach ($route['middlewares'] as $middlewareClass) { + $middleware = new $middlewareClass(); + $middleware->handle($request); + } + + $params = $this->extractParams($route['path'], $path); + return $this->callHandler($route['handler'], $request, $params); + } + } + + return new Response(['error' => 'Route not found'], 404); + } + + private function matchPath(string $routePath, string $requestPath): bool + { + $pattern = preg_replace('/\{[^}]+\}/', '([^/]+)', $routePath); + $pattern = '#^' . $pattern . '$#'; + return preg_match($pattern, $requestPath); + } + + private function extractParams(string $routePath, string $requestPath): array + { + $routeParts = explode('/', $routePath); + $requestParts = explode('/', $requestPath); + $params = []; + + foreach ($routeParts as $index => $part) { + if (strpos($part, '{') === 0 && strpos($part, '}') === strlen($part) - 1) { + $paramName = substr($part, 1, -1); + $params[$paramName] = $requestParts[$index] ?? null; + } + } + + return $params; + } + + private function callHandler($handler, Request $request, array $params): Response + { + if (is_callable($handler)) { + return $handler($request, $params); + } + + if (is_string($handler) && strpos($handler, '@') !== false) { + [$className, $method] = explode('@', $handler); + $controller = new $className(); + return $controller->$method($request, $params); + } + + return new Response(['error' => 'Invalid handler'], 500); + } +} +"#; + + pub const VANILLA_REQUEST: &str = r#"method = strtoupper($method); + $this->path = $path; + $this->query = $query; + $this->body = $body; + $this->headers = $headers; + } + + public static function createFromGlobals(): self + { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); + $query = $_GET; + + $body = []; + if (in_array($method, ['POST', 'PUT', 'PATCH'])) { + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (strpos($contentType, 'application/json') !== false) { + $input = file_get_contents('php://input'); + $body = json_decode($input, true) ?? []; + } else { + $body = $_POST; + } + } + + $headers = []; + foreach ($_SERVER as $key => $value) { + if (strpos($key, 'HTTP_') === 0) { + $headerName = str_replace('_', '-', substr($key, 5)); + $headers[strtolower($headerName)] = $value; + } + } + + return new self($method, $path, $query, $body, $headers); + } + + public function getMethod(): string + { + return $this->method; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(string $key = null, $default = null) + { + if ($key === null) { + return $this->query; + } + return $this->query[$key] ?? $default; + } + + public function getBody(string $key = null, $default = null) + { + if ($key === null) { + return $this->body; + } + return $this->body[$key] ?? $default; + } + + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader(string $name): bool + { + return isset($this->headers[strtolower($name)]); + } + + public function getBearerToken(): ?string + { + $authorization = $this->getHeader('authorization'); + if ($authorization && strpos($authorization, 'Bearer ') === 0) { + return substr($authorization, 7); + } + return null; + } +} +"#; + + pub const VANILLA_RESPONSE: &str = r#"data = $data; + $this->statusCode = $statusCode; + $this->headers = array_merge([ + 'Content-Type' => 'application/json', + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', + ], $headers); + } + + public function send(): void + { + http_response_code($this->statusCode); + + foreach ($this->headers as $name => $value) { + header("$name: $value"); + } + + if ($this->data !== null) { + echo json_encode($this->data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + } + } + + public function withHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + return $this; + } + + public function withStatus(int $statusCode): self + { + $this->statusCode = $statusCode; + return $this; + } + + public static function json($data, int $statusCode = 200): self + { + return new self($data, $statusCode); + } + + public static function success($data = null, string $message = 'Success'): self + { + return new self([ + 'success' => true, + 'message' => $message, + 'data' => $data + ], 200); + } + + public static function error(string $message, int $statusCode = 400, $errors = null): self + { + $response = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors !== null) { + $response['errors'] = $errors; + } + + return new self($response, $statusCode); + } +} +"#; + + pub const VANILLA_PDO_CONNECTION: &str = r#" PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (PDOException $e) { + throw new PDOException("Database connection failed: " . $e->getMessage()); + } + } + + return self::$instance; + } + + private static function buildDsn(array $config): string + { + $driver = $config['driver']; + $host = $config['host']; + $port = $config['port']; + $database = $config['database']; + + switch ($driver) { + case 'pgsql': + return "pgsql:host=$host;port=$port;dbname=$database"; + case 'mysql': + $charset = $config['charset'] ?? 'utf8mb4'; + return "mysql:host=$host;port=$port;dbname=$database;charset=$charset"; + default: + throw new \InvalidArgumentException("Unsupported database driver: $driver"); + } + } + + public static function beginTransaction(): void + { + self::getInstance()->beginTransaction(); + } + + public static function commit(): void + { + self::getInstance()->commit(); + } + + public static function rollback(): void + { + self::getInstance()->rollback(); + } +} +"#; + + pub const VANILLA_JWT_MANAGER: &str = r#"secret = $config['secret']; + $this->algorithm = $config['algorithm']; + $this->ttl = $config['ttl']; + } + + public function encode(array $payload): string + { + $now = time(); + $payload = array_merge($payload, [ + 'iat' => $now, + 'exp' => $now + $this->ttl, + 'iss' => AppConfig::get('app.url'), + ]); + + return JWT::encode($payload, $this->secret, $this->algorithm); + } + + public function decode(string $token): array + { + try { + $decoded = JWT::decode($token, new Key($this->secret, $this->algorithm)); + return (array) $decoded; + } catch (ExpiredException $e) { + throw new \InvalidArgumentException('Token has expired'); + } catch (SignatureInvalidException $e) { + throw new \InvalidArgumentException('Invalid token signature'); + } catch (\Exception $e) { + throw new \InvalidArgumentException('Invalid token'); + } + } + + public function validate(string $token): bool + { + try { + $this->decode($token); + return true; + } catch (\Exception $e) { + return false; + } + } + + public function getUserIdFromToken(string $token): ?string + { + try { + $payload = $this->decode($token); + return $payload['user_id'] ?? null; + } catch (\Exception $e) { + return null; + } + } +} +"#; + + pub const VANILLA_USER_ENTITY: &str = r#"id = $id; + $this->name = $name; + $this->email = $email; + $this->passwordHash = $passwordHash; + $this->createdAt = $createdAt ?? new \DateTimeImmutable(); + $this->updatedAt = $updatedAt; + } + + public function getId(): UserId + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getEmail(): Email + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function changeName(string $name): void + { + $this->name = $name; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function changeEmail(Email $email): void + { + $this->email = $email; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function changePassword(string $passwordHash): void + { + $this->passwordHash = $passwordHash; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function verifyPassword(string $password): bool + { + return password_verify($password, $this->passwordHash); + } + + public function toArray(): array + { + return [ + 'id' => $this->id->getValue(), + 'name' => $this->name, + 'email' => $this->email->getValue(), + 'created_at' => $this->createdAt->format('c'), + 'updated_at' => $this->updatedAt?->format('c'), + ]; + } +} +"#; + + pub const VANILLA_USER_REPOSITORY_INTERFACE: &str = r#"userRepository = $userRepository; + } + + public function emailExists(Email $email): bool + { + return $this->userRepository->existsByEmail($email); + } + + public function createPasswordHash(string $password): string + { + $cost = (int) ($_ENV['BCRYPT_ROUNDS'] ?? 12); + return password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]); + } + + public function validatePassword(string $password): array + { + $errors = []; + + if (strlen($password) < 8) { + $errors[] = 'Password must be at least 8 characters long'; + } + + if (!preg_match('/[A-Z]/', $password)) { + $errors[] = 'Password must contain at least one uppercase letter'; + } + + if (!preg_match('/[a-z]/', $password)) { + $errors[] = 'Password must contain at least one lowercase letter'; + } + + if (!preg_match('/[0-9]/', $password)) { + $errors[] = 'Password must contain at least one number'; + } + + return $errors; + } + + public function authenticateUser(Email $email, string $password): ?User + { + $user = $this->userRepository->findByEmail($email); + + if ($user && $user->verifyPassword($password)) { + return $user; + } + + return null; + } +} +"#; + + pub const VANILLA_EMAIL_VALUE_OBJECT: &str = r#"value = strtolower(trim($value)); + } + + public function getValue(): string + { + return $this->value; + } + + public function equals(Email $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} +"#; + + pub const VANILLA_USER_ID_VALUE_OBJECT: &str = r#"value = $value; + } + + public static function generate(): self + { + return new self(Uuid::uuid4()->toString()); + } + + public function getValue(): string + { + return $this->value; + } + + public function equals(UserId $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} +"#; + + pub const VANILLA_CREATE_USER_COMMAND: &str = r#"name = trim($name); + $this->email = trim($email); + $this->password = $password; + } + + public function getName(): string + { + return $this->name; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPassword(): string + { + return $this->password; + } +} +"#; + + pub const VANILLA_CREATE_USER_HANDLER: &str = r#"userRepository = $userRepository; + $this->userService = $userService; + } + + public function handle(CreateUserCommand $command): User + { + // Validate input + if (empty($command->getName())) { + throw new \InvalidArgumentException('Name is required'); + } + + $email = new Email($command->getEmail()); + + // Check if email already exists + if ($this->userService->emailExists($email)) { + throw new \InvalidArgumentException('Email already exists'); + } + + // Validate password + $passwordErrors = $this->userService->validatePassword($command->getPassword()); + if (!empty($passwordErrors)) { + throw new \InvalidArgumentException('Password validation failed: ' . implode(', ', $passwordErrors)); + } + + // Create user + $user = new User( + UserId::generate(), + $command->getName(), + $email, + $this->userService->createPasswordHash($command->getPassword()) + ); + + $this->userRepository->save($user); + + return $user; + } +} +"#; + + pub const VANILLA_LOGIN_COMMAND: &str = r#"email = trim($email); + $this->password = $password; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPassword(): string + { + return $this->password; + } +} +"#; + + pub const VANILLA_LOGIN_HANDLER: &str = r#"userService = $userService; + $this->jwtManager = $jwtManager; + } + + public function handle(LoginCommand $command): array + { + if (empty($command->getEmail()) || empty($command->getPassword())) { + throw new \InvalidArgumentException('Email and password are required'); + } + + $email = new Email($command->getEmail()); + $user = $this->userService->authenticateUser($email, $command->getPassword()); + + if (!$user) { + throw new \InvalidArgumentException('Invalid credentials'); + } + + $token = $this->jwtManager->encode([ + 'user_id' => $user->getId()->getValue(), + 'email' => $user->getEmail()->getValue(), + ]); + + return [ + 'token' => $token, + 'user' => $user->toArray(), + ]; + } +} +"#; + + pub const VANILLA_AUTH_CONTROLLER: &str = r#"jwtManager = new JWTManager(); + + $this->createUserHandler = new CreateUserHandler($userRepository, $userService); + $this->loginHandler = new LoginHandler($userService, $this->jwtManager); + } + + public function register(Request $request): Response + { + try { + $data = $request->getBody(); + + $command = new CreateUserCommand( + $data['name'] ?? '', + $data['email'] ?? '', + $data['password'] ?? '' + ); + + $user = $this->createUserHandler->handle($command); + + return Response::success($user->toArray(), 'User created successfully'); + } catch (\InvalidArgumentException $e) { + return Response::error($e->getMessage(), 400); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } + + public function login(Request $request): Response + { + try { + $data = $request->getBody(); + + $command = new LoginCommand( + $data['email'] ?? '', + $data['password'] ?? '' + ); + + $result = $this->loginHandler->handle($command); + + return Response::success($result, 'Login successful'); + } catch (\InvalidArgumentException $e) { + return Response::error($e->getMessage(), 401); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } + + public function logout(Request $request): Response + { + // In a stateless JWT implementation, logout is handled client-side + // You could implement a token blacklist here if needed + return Response::success(null, 'Logout successful'); + } + + public function me(Request $request): Response + { + try { + $token = $request->getBearerToken(); + + if (!$token) { + return Response::error('Token not provided', 401); + } + + $userId = $this->jwtManager->getUserIdFromToken($token); + + if (!$userId) { + return Response::error('Invalid token', 401); + } + + $userRepository = new UserRepository(); + $user = $userRepository->findById(new \App\Domain\User\ValueObject\UserId($userId)); + + if (!$user) { + return Response::error('User not found', 404); + } + + return Response::success($user->toArray()); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } +} +"#; + + pub const VANILLA_USER_CONTROLLER: &str = r#"userRepository = new UserRepository(); + $userService = new UserService($this->userRepository); + $this->createUserHandler = new CreateUserHandler($this->userRepository, $userService); + } + + public function index(Request $request): Response + { + try { + $users = $this->userRepository->findAll(); + $usersArray = array_map(fn($user) => $user->toArray(), $users); + + return Response::success($usersArray); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } + + public function show(Request $request, array $params): Response + { + try { + $userId = new UserId($params['id']); + $user = $this->userRepository->findById($userId); + + if (!$user) { + return Response::error('User not found', 404); + } + + return Response::success($user->toArray()); + } catch (\InvalidArgumentException $e) { + return Response::error('Invalid user ID', 400); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } + + public function store(Request $request): Response + { + try { + $data = $request->getBody(); + + $command = new CreateUserCommand( + $data['name'] ?? '', + $data['email'] ?? '', + $data['password'] ?? '' + ); + + $user = $this->createUserHandler->handle($command); + + return Response::success($user->toArray(), 'User created successfully'); + } catch (\InvalidArgumentException $e) { + return Response::error($e->getMessage(), 400); + } catch (\Exception $e) { + return Response::error('Internal server error', 500); + } + } +} +"#; + + pub const VANILLA_USER_REPOSITORY: &str = r#"pdo = PDOConnection::getInstance(); + } + + public function save(User $user): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO users (id, name, email, password_hash, created_at, updated_at) + VALUES (:id, :name, :email, :password_hash, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at' + ); + + $stmt->execute([ + 'id' => $user->getId()->getValue(), + 'name' => $user->getName(), + 'email' => $user->getEmail()->getValue(), + 'password_hash' => $user->getPasswordHash(), + 'created_at' => $user->getCreatedAt()->format('Y-m-d H:i:s'), + 'updated_at' => $user->getUpdatedAt()?->format('Y-m-d H:i:s'), + ]); + } + + public function findById(UserId $id): ?User + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id'); + $stmt->execute(['id' => $id->getValue()]); + $data = $stmt->fetch(); + + return $data ? $this->mapToUser($data) : null; + } + + public function findByEmail(Email $email): ?User + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email'); + $stmt->execute(['email' => $email->getValue()]); + $data = $stmt->fetch(); + + return $data ? $this->mapToUser($data) : null; + } + + public function findAll(): array + { + $stmt = $this->pdo->query('SELECT * FROM users ORDER BY created_at DESC'); + $results = $stmt->fetchAll(); + + return array_map([$this, 'mapToUser'], $results); + } + + public function delete(UserId $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = :id'); + $stmt->execute(['id' => $id->getValue()]); + } + + public function existsByEmail(Email $email): bool + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM users WHERE email = :email'); + $stmt->execute(['email' => $email->getValue()]); + + return $stmt->fetchColumn() > 0; + } + + private function mapToUser(array $data): User + { + return new User( + new UserId($data['id']), + $data['name'], + new Email($data['email']), + $data['password_hash'], + new \DateTimeImmutable($data['created_at']), + $data['updated_at'] ? new \DateTimeImmutable($data['updated_at']) : null + ); + } +} +"#; + + pub const VANILLA_AUTH_MIDDLEWARE: &str = r#"jwtManager = new JWTManager(); + } + + public function handle(Request $request): void + { + $token = $request->getBearerToken(); + + if (!$token) { + $this->unauthorized('Token not provided'); + } + + if (!$this->jwtManager->validate($token)) { + $this->unauthorized('Invalid or expired token'); + } + + // Token is valid, continue with the request + } + + private function unauthorized(string $message): void + { + $response = Response::error($message, 401); + $response->send(); + exit; + } +} +"#; + + pub const VANILLA_CORS_MIDDLEWARE: &str = r#"getMethod() === 'OPTIONS') { + $this->sendCorsHeaders($config); + http_response_code(200); + exit; + } + + // Add CORS headers to all responses + $this->sendCorsHeaders($config); + } + + private function sendCorsHeaders(array $config): void + { + $origin = $_SERVER['HTTP_ORIGIN'] ?? '*'; + $allowedOrigins = $config['allowed_origins']; + + if (in_array('*', $allowedOrigins) || in_array($origin, $allowedOrigins)) { + header("Access-Control-Allow-Origin: $origin"); + } + + header('Access-Control-Allow-Methods: ' . implode(', ', $config['allowed_methods'])); + header('Access-Control-Allow-Headers: ' . implode(', ', $config['allowed_headers'])); + + if (!empty($config['expose_headers'])) { + header('Access-Control-Expose-Headers: ' . implode(', ', $config['expose_headers'])); + } + + if ($config['supports_credentials']) { + header('Access-Control-Allow-Credentials: true'); + } + + header('Access-Control-Max-Age: ' . $config['max_age']); + } +} +"#; + + pub const VANILLA_USERS_MIGRATION: &str = r#"-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); +"#; + + pub const VANILLA_README: &str = r#"# {{project_name}} + +Production-ready PHP Vanilla application with Clean Architecture, Domain-Driven Design (DDD), and comprehensive JWT authentication. + +## Architecture + +This project follows **Clean Architecture** principles with clear separation of concerns: + +### Directory Structure + +``` +src/ +├── Domain/ # Business logic and entities +│ ├── User/ +│ │ ├── Entity/ # Domain entities (User.php) +│ │ ├── Repository/ # Repository interfaces +│ │ ├── Service/ # Domain services +│ │ └── ValueObject/ # Value objects (Email, UserId) +│ └── Auth/ +│ └── Service/ # Authentication domain services +├── Application/ # Use cases and application logic +│ ├── User/ +│ │ ├── Command/ # Command objects +│ │ └── Handler/ # Command handlers +│ └── Auth/ +│ ├── Command/ +│ └── Handler/ +└── Infrastructure/ # External concerns + ├── Http/ + │ ├── Controller/ # API controllers + │ └── Middleware/ # HTTP middleware + ├── Persistence/ + │ └── PDO/ # Repository implementations + ├── Security/ # JWT management + └── Database/ # Database connections +``` + +## Features + +- **Clean Architecture** with Domain-Driven Design +- **JWT Authentication** with access tokens +- **Repository Pattern** for data access abstraction +- **Command/Handler Pattern** (CQRS-lite) +- **Docker** containerization with Nginx + PHP-FPM +- **PostgreSQL** database with migrations +- **PSR-4** autoloading +- **Comprehensive Testing** (Unit, Integration, Functional) +- **Code Quality** tools (PHPStan, PHP-CS-Fixer) + +## Quick Start + +### With Docker (Recommended) + +```bash +# Clone and setup +git clone +cd {{kebab_case}} + +# Environment setup +cp .env.example .env +# Edit .env with your configuration + +# Build and start containers +docker-compose up --build -d + +# Install dependencies +docker-compose exec app composer install + +# Run database migrations +docker-compose exec app php database/migrate.php + +# The API will be available at http://localhost:8000 +``` + +### Local Development + +```bash +# Install dependencies +composer install + +# Environment setup +cp .env.example .env +# Edit .env with your configuration + +# Database setup (make sure PostgreSQL is running) +php database/migrate.php + +# Start development server +php -S localhost:8000 public/index.php +``` + +## API Documentation + +### Authentication Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/auth/register` | User registration | +| POST | `/api/v1/auth/login` | User login | +| POST | `/api/v1/auth/logout` | User logout | +| GET | `/api/v1/auth/me` | Get current user | + +### User Endpoints (Protected) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/users` | List users | +| GET | `/api/v1/users/{id}` | Get user by ID | +| POST | `/api/v1/users` | Create user | + +### System Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/health` | Health check | + +## Testing + +```bash +# Run all tests +composer test + +# Run specific test suite +./vendor/bin/phpunit --testsuite=Unit +./vendor/bin/phpunit --testsuite=Integration +./vendor/bin/phpunit --testsuite=Functional +``` + +## Code Quality + +```bash +# Fix code style +composer cs-fix + +# Run static analysis +composer stan + +# Run all quality checks +composer cs-fix && composer stan && composer test +``` + +## Docker Services + +- **app**: PHP 8.2-FPM with vanilla PHP application +- **nginx**: Nginx web server (reverse proxy) +- **postgres**: PostgreSQL 16 database + +## Security Features + +- JWT token-based authentication +- Password hashing with bcrypt +- CORS configuration +- Security headers (X-Frame-Options, CSP, etc.) +- Input validation and sanitization +- SQL injection prevention with prepared statements + +## Environment Variables + +Key environment variables to configure: + +```env +APP_NAME={{project_name}} +APP_ENV=production +APP_URL=https://yourdomain.com + +DB_CONNECTION=pgsql +DB_HOST=postgres +DB_DATABASE={{snake_case}}_db +DB_USERNAME=postgres +DB_PASSWORD=your-secure-password + +JWT_SECRET=your-jwt-secret +JWT_TTL=3600 + +BCRYPT_ROUNDS=12 +``` + +## Deployment + +1. Set up your production environment +2. Configure environment variables +3. Build Docker images +4. Deploy with docker-compose or Kubernetes +5. Run migrations: `php database/migrate.php` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new features +4. Ensure code quality checks pass +5. Submit a pull request + +--- + +Generated by Athena CLI +"#; + + pub const VANILLA_APP_CONFIG_CLASS: &str = r#" include __DIR__ . '/../../../config/app.php', + 'database' => include __DIR__ . '/../../../config/database.php', + ]; + } + } + + public static function get(string $key, $default = null) + { + if (empty(self::$config)) { + self::load(); + } + + $keys = explode('.', $key); + $value = self::$config; + + foreach ($keys as $k) { + if (!isset($value[$k])) { + return $default; + } + $value = $value[$k]; + } + + return $value; + } + + public static function all(): array + { + if (empty(self::$config)) { + self::load(); + } + + return self::$config; + } +} +"#; + + pub const VANILLA_PHPUNIT_XML: &str = r#" + + + + ./tests/Unit + + + ./tests/Integration + + + ./tests/Functional + + + + + ./src + + + +"#; + + pub const VANILLA_USER_TEST: &str = r#"assertEquals($id, $user->getId()); + $this->assertEquals($name, $user->getName()); + $this->assertEquals($email, $user->getEmail()); + $this->assertEquals($passwordHash, $user->getPasswordHash()); + $this->assertInstanceOf(\DateTimeImmutable::class, $user->getCreatedAt()); + $this->assertNull($user->getUpdatedAt()); + } + + public function testUserCanChangePassword(): void + { + $user = $this->createUser(); + $newPasswordHash = password_hash('newpassword123', PASSWORD_BCRYPT); + + $user->changePassword($newPasswordHash); + + $this->assertEquals($newPasswordHash, $user->getPasswordHash()); + $this->assertInstanceOf(\DateTimeImmutable::class, $user->getUpdatedAt()); + } + + public function testUserCanVerifyPassword(): void + { + $password = 'password123'; + $user = $this->createUser($password); + + $this->assertTrue($user->verifyPassword($password)); + $this->assertFalse($user->verifyPassword('wrongpassword')); + } + + public function testUserCanBeConvertedToArray(): void + { + $user = $this->createUser(); + $array = $user->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('id', $array); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('email', $array); + $this->assertArrayHasKey('created_at', $array); + $this->assertArrayHasKey('updated_at', $array); + } + + private function createUser(string $password = 'password123'): User + { + return new User( + UserId::generate(), + 'John Doe', + new Email('john@example.com'), + password_hash($password, PASSWORD_BCRYPT) + ); + } +} +"#; + + pub const VANILLA_AUTH_FUNCTIONAL_TEST: &str = r#"assertTrue(true); + } + + public function testUserRegistration(): void + { + // This is a placeholder for user registration functional test + $this->assertTrue(true); + } + + public function testUserLogin(): void + { + // This is a placeholder for user login functional test + $this->assertTrue(true); + } +} +"#; } diff --git a/src/cli/args.rs b/src/cli/args.rs index 572d2f3..b2b314a 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -167,6 +167,23 @@ pub enum InitCommands { #[arg(long)] no_docker: bool, }, + + /// Generate a PHP Vanilla project boilerplate with Clean Architecture + Vanilla { + /// Project name + name: String, + + /// Output directory (defaults to project name) + directory: Option, + + /// Include MySQL configuration instead of PostgreSQL + #[arg(long)] + with_mysql: bool, + + /// Skip Docker files generation + #[arg(long)] + no_docker: bool, + }, } #[derive(clap::ValueEnum, Debug, Clone)] diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 1bce05d..43e1f40 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -4,7 +4,7 @@ use std::path::Path; use crate::athena::{generate_docker_compose, parse_athena_file, AthenaError, AthenaResult}; use crate::boilerplate::{ generate_fastapi_project, generate_flask_project, generate_go_project, - generate_laravel_project, generate_symfony_project, + generate_laravel_project, generate_symfony_project, generate_vanilla_project, DatabaseType, GoFramework, ProjectConfig, }; use crate::cli::args::{Commands, InitCommands}; @@ -280,6 +280,39 @@ fn execute_init(init_cmd: InitCommands, verbose: bool) -> AthenaResult<()> { generate_symfony_project(&config)?; Ok(()) } + + InitCommands::Vanilla { + name, + directory, + with_mysql, + no_docker, + } => { + let db_type = if with_mysql { "MySQL" } else { "PostgreSQL" }; + if verbose { + println!("Initializing PHP Vanilla project with {}: {}", db_type, name); + } + + // Choose database type + let database = if with_mysql { + DatabaseType::MySQL + } else { + DatabaseType::PostgreSQL + }; + + // Determine directory + let project_dir = directory.unwrap_or_else(|| Path::new(&name).to_path_buf()); + + let config = ProjectConfig { + name: name.clone(), + directory: project_dir.to_string_lossy().to_string(), + database, + include_docker: !no_docker, + framework: None, // Not applicable for Vanilla + }; + + generate_vanilla_project(&config)?; + Ok(()) + } } } diff --git a/tests/integration/boilerplate/mod.rs b/tests/integration/boilerplate/mod.rs index a116559..036be23 100644 --- a/tests/integration/boilerplate/mod.rs +++ b/tests/integration/boilerplate/mod.rs @@ -9,6 +9,7 @@ pub mod go_tests; pub mod common_tests; pub mod laravel_tests; pub mod symfony_tests; +pub mod vanilla_tests; // Common test utilities for boilerplate generation tests @@ -145,4 +146,10 @@ pub fn check_for_doctrine_configuration(project_dir: &Path) -> bool { check_file_contains_any(project_dir, &[ "composer.json", "config/packages/doctrine.yaml" ], &["doctrine/orm", "doctrine/doctrine-bundle", "doctrine/migrations"]) +} + +pub fn check_for_vanilla_configuration(project_dir: &Path) -> bool { + check_file_contains_any(project_dir, &[ + "composer.json", "public/index.php", "src/Infrastructure/Http/Router.php" + ], &["firebase/php-jwt", "App\\Infrastructure\\Http\\Router", "Clean Architecture"]) } \ No newline at end of file diff --git a/tests/integration/boilerplate/vanilla_tests.rs b/tests/integration/boilerplate/vanilla_tests.rs new file mode 100644 index 0000000..98e40da --- /dev/null +++ b/tests/integration/boilerplate/vanilla_tests.rs @@ -0,0 +1,324 @@ +use super::*; +use serial_test::serial; +use tempfile::TempDir; + +#[test] +#[serial] +fn test_vanilla_init_basic() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_basic"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert() + .success() + .stdout(predicate::str::contains("PHP Vanilla project")) + .stdout(predicate::str::contains(project_name)); + + // Verify project structure was created + let project_dir = temp_dir.path().join(project_name); + assert!(project_dir.exists(), "Project directory should be created"); + + // Check for PHP Vanilla files that we know exist + let expected_files = &[ + "composer.json", + "docker-compose.yml", + ".env.docker.example", + ".env.example", + "README.md", + "public/index.php", + "public/.htaccess", + "src/Domain/User/Entity/User.php", + "src/Application/User/Command/CreateUserCommand.php", + "src/Infrastructure/Http/Controller/Api/V1/AuthController.php", + "src/Infrastructure/Http/Router.php", + "src/Infrastructure/Database/PDOConnection.php", + "src/Infrastructure/Security/JWTManager.php", + "src/Infrastructure/Config/AppConfig.php", + ]; + + for file in expected_files { + assert!(project_dir.join(file).exists(), "{} should exist", file); + } +} + +#[test] +#[serial] +fn test_vanilla_docker_compose_structure() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_docker"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check docker-compose.yml contains production-ready configuration + let docker_compose_path = project_dir.join("docker-compose.yml"); + assert!(docker_compose_path.exists(), "docker-compose.yml should exist"); + + let docker_compose_content = fs::read_to_string(&docker_compose_path) + .expect("Should be able to read docker-compose.yml"); + + // Check for production-ready features + assert!(docker_compose_content.contains("env_file:"), "Should use env_file for security"); + assert!(docker_compose_content.contains("expose:"), "Should use expose instead of ports for internal services"); + assert!(docker_compose_content.contains("healthcheck:"), "Should have health checks"); + assert!(docker_compose_content.contains("depends_on:"), "Should have service dependencies"); + assert!(docker_compose_content.contains("restart: unless-stopped"), "Should have restart policy"); +} + +#[test] +#[serial] +fn test_vanilla_clean_architecture_structure() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_architecture"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check for Clean Architecture directories + let architecture_dirs = &[ + "src/Domain", + "src/Domain/User", + "src/Domain/User/Entity", + "src/Domain/User/Repository", + "src/Domain/User/Service", + "src/Domain/User/ValueObject", + "src/Application", + "src/Application/User/Command", + "src/Application/User/Handler", + "src/Application/Auth", + "src/Infrastructure", + "src/Infrastructure/Http/Controller", + "src/Infrastructure/Persistence/PDO", + "src/Infrastructure/Security", + "src/Infrastructure/Config", + ]; + + for dir in architecture_dirs { + assert!(project_dir.join(dir).exists(), "{} directory should exist", dir); + } +} + +#[test] +#[serial] +fn test_vanilla_jwt_authentication() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_jwt"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check composer.json contains JWT dependency + let composer_path = project_dir.join("composer.json"); + let composer_content = fs::read_to_string(&composer_path) + .expect("Should be able to read composer.json"); + + assert!(composer_content.contains("firebase/php-jwt"), "Should include JWT library"); + + // Check for JWT-related files + assert!(project_dir.join("src/Infrastructure/Security/JWTManager.php").exists(), + "JWTManager should exist"); + assert!(project_dir.join("src/Infrastructure/Http/Controller/Api/V1/AuthController.php").exists(), + "AuthController should exist"); +} + +#[test] +#[serial] +fn test_vanilla_pdo_database() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_database"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check for database configuration + assert!(project_dir.join("config/database.php").exists(), "Database config should exist"); + assert!(project_dir.join("src/Infrastructure/Database/PDOConnection.php").exists(), + "PDO connection should exist"); + assert!(project_dir.join("database/migrations/001_create_users_table.sql").exists(), + "Database migration should exist"); + + // Check database config content + let db_config_path = project_dir.join("config/database.php"); + let db_config_content = fs::read_to_string(&db_config_path) + .expect("Should be able to read database config"); + + assert!(db_config_content.contains("pgsql"), "Should support PostgreSQL"); + assert!(db_config_content.contains("mysql"), "Should support MySQL"); +} + +#[test] +#[serial] +fn test_vanilla_environment_security() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_security"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check .env.example exists with secure variables + let env_example_path = project_dir.join(".env.example"); + assert!(env_example_path.exists(), ".env.example should exist"); + + let env_content = fs::read_to_string(&env_example_path) + .expect("Should be able to read .env.example"); + + // Should contain environment variable templates + assert!(env_content.contains("APP_NAME="), "Should have APP_NAME template"); + assert!(env_content.contains("DB_PASSWORD="), "Should have DB_PASSWORD template"); + assert!(env_content.contains("JWT_SECRET="), "Should have JWT_SECRET template"); + assert!(env_content.contains("BCRYPT_ROUNDS="), "Should have BCRYPT_ROUNDS setting"); +} + +#[test] +#[serial] +fn test_vanilla_testing_structure() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_testing"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check for testing configuration + assert!(project_dir.join("phpunit.xml").exists(), "phpunit.xml should exist"); + + // Check composer.json contains testing dependencies + let composer_path = project_dir.join("composer.json"); + let composer_content = fs::read_to_string(&composer_path) + .expect("Should be able to read composer.json"); + + assert!(composer_content.contains("phpunit/phpunit"), "Should include PHPUnit"); + assert!(composer_content.contains("phpstan/phpstan"), "Should include PHPStan"); + assert!(composer_content.contains("friendsofphp/php-cs-fixer"), "Should include PHP CS Fixer"); + + // Check for test directories + let test_dirs = &[ + "tests/Unit", + "tests/Integration", + "tests/Functional", + ]; + + for dir in test_dirs { + assert!(project_dir.join(dir).exists(), "{} directory should exist", dir); + } + + // Check for test files + assert!(project_dir.join("tests/Unit/UserTest.php").exists(), "Unit test should exist"); + assert!(project_dir.join("tests/Functional/AuthTest.php").exists(), "Functional test should exist"); +} + +#[test] +#[serial] +fn test_vanilla_no_docker() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_no_docker"; + + let mut cmd = run_init_command("vanilla", project_name, &["--no-docker"]); + cmd.current_dir(&temp_dir); + + cmd.assert() + .success() + .stdout(predicate::str::contains("PHP Vanilla project")); + + let project_dir = temp_dir.path().join(project_name); + assert!(project_dir.exists(), "Project directory should be created"); + + // Docker files should NOT exist + assert!(!project_dir.join("docker-compose.yml").exists(), + "docker-compose.yml should not exist with --no-docker"); + assert!(!project_dir.join("docker/php/Dockerfile").exists(), + "Dockerfile should not exist with --no-docker"); + assert!(!project_dir.join(".env.docker.example").exists(), + ".env.docker.example should not exist with --no-docker"); + + // But regular PHP files should exist + assert!(project_dir.join("composer.json").exists(), "composer.json should exist"); + assert!(project_dir.join("public/index.php").exists(), "index.php should exist"); +} + +#[test] +#[serial] +fn test_vanilla_api_structure() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_api"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check for API structure + assert!(project_dir.join("src/Infrastructure/Http/Router.php").exists(), "Router should exist"); + assert!(project_dir.join("src/Infrastructure/Http/Request.php").exists(), "Request class should exist"); + assert!(project_dir.join("src/Infrastructure/Http/Response.php").exists(), "Response class should exist"); + + // Check public/index.php has API routes defined + let index_php_path = project_dir.join("public/index.php"); + let index_content = fs::read_to_string(&index_php_path) + .expect("Should be able to read index.php"); + + assert!(index_content.contains("/api/v1/health"), "Should have health endpoint"); + assert!(index_content.contains("/api/v1/auth/register"), "Should have register endpoint"); + assert!(index_content.contains("/api/v1/auth/login"), "Should have login endpoint"); + assert!(index_content.contains("/api/v1/users"), "Should have users endpoint"); +} + +#[test] +#[serial] +fn test_vanilla_psr4_autoloading() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let project_name = "test_vanilla_psr4"; + + let mut cmd = run_init_command("vanilla", project_name, &[]); + cmd.current_dir(&temp_dir); + + cmd.assert().success(); + + let project_dir = temp_dir.path().join(project_name); + + // Check composer.json contains PSR-4 autoloading + let composer_path = project_dir.join("composer.json"); + let composer_content = fs::read_to_string(&composer_path) + .expect("Should be able to read composer.json"); + + assert!(composer_content.contains("\"App\\\\\""), "Should have PSR-4 autoloading for App namespace"); + assert!(composer_content.contains("\"Tests\\\\\""), "Should have PSR-4 autoloading for Tests namespace"); +} + +#[test] +fn test_vanilla_init_help() { + let mut cmd = Command::cargo_bin("athena").expect("Failed to find athena binary"); + cmd.arg("init").arg("vanilla").arg("--help"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("PHP Vanilla")) + .stdout(predicate::str::contains("--no-docker")); +} \ No newline at end of file