diff --git a/.env.example b/.env.example deleted file mode 100644 index 1162a30..0000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -APP_ENV=development -HTTP_MAX_BODY_SIZE=10485760 - -# Database driver - choose one: -# DB_DRIVER=mysql -# DB_DRIVER=pgsql -# DB_DRIVER=sqlite -DB_DRIVER=mysql - -# MySQL / PostgreSQL -DB_HOST=127.0.0.1 -DB_PORT= -DB_NAME=meulah -DB_USER=root -DB_PASS= - -# SQLite only; relative paths resolve from the project root. -# Use :memory: for an in-memory database. -DB_PATH=database.sqlite - - - -DB_MIGRATIONS=database/migrations -DB_MIGRATION_TABLE=meulah_migrations diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4dfad71 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + framework: + name: Framework / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.5'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: fileinfo, pdo, pdo_sqlite + coverage: none + tools: composer:v2 + + - name: Install framework + run: composer install --no-interaction --prefer-dist + + - name: Validate framework package + run: | + composer validate --strict + composer validate --strict skeleton/composer.json + + - name: Lint framework, starter, and tests + run: find src skeleton tests -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Run framework tests + run: composer test + + starter: + name: Starter / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: fileinfo, pdo, pdo_sqlite + coverage: none + tools: composer:v2 + + - name: Create clean starter consumer + shell: bash + run: php tests/create-starter.php "$RUNNER_TEMP/meulah-app" + + - name: Install starter dependencies + shell: bash + working-directory: ${{ runner.temp }}/meulah-app + run: composer install --no-interaction --prefer-dist + + - name: Validate starter package + shell: bash + working-directory: ${{ runner.temp }}/meulah-app + run: composer validate --strict + + - name: Exercise web bootstrap and CLI entry points + shell: bash + working-directory: ${{ runner.temp }}/meulah-app + env: + DB_DRIVER: sqlite + DB_PATH: ':memory:' + run: | + php public/index.php > response.html + grep -q 'Your Meulah application is ready.' response.html + php meulah --help + php vendor/bin/meulah --help + php vendor/meulah/framework/bin/meulah --help + cd database/migrations + php ../../meulah migrate:status + php ../../vendor/bin/meulah migrate:status + php ../../vendor/meulah/framework/bin/meulah migrate:status + cd ../../app/Controllers + php ../../meulah --help + php ../../vendor/bin/meulah --help + php ../../vendor/meulah/framework/bin/meulah --help + + - name: Verify production dependency install + shell: bash + working-directory: ${{ runner.temp }}/meulah-app + env: + DB_DRIVER: sqlite + DB_PATH: ':memory:' + run: | + rm -rf vendor + composer install --no-dev --no-interaction --prefer-dist + php public/index.php > production-response.html + grep -q 'Your Meulah application is ready.' production-response.html + php meulah migrate:status diff --git a/.gitignore b/.gitignore index 3c89b85..d8adb67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ /.idea/ /.vscode/ -/.env /vendor/ -/database.sqlite -/storage/logs/*.log /.phpunit.cache/ /.phpunit.result.cache .DS_Store diff --git a/.htaccess b/.htaccess deleted file mode 100644 index d64d248..0000000 --- a/.htaccess +++ /dev/null @@ -1,5 +0,0 @@ - - RewriteEngine on - RewriteRule ^$ public/ [L] - RewriteRule (.*) public/$1 [L] - \ No newline at end of file diff --git a/README.md b/README.md index 56bc515..ccd083c 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,23 @@ Meulah is a small, explicit PHP framework for conventional server-rendered appli Meulah currently requires PHP 8.1 or newer. +## Package boundary + +The repository now contains two distinct Composer packages: + +```text +src/ reusable framework code (meulah/framework) +bin/ executable installed as vendor/bin/meulah +tests/ framework contract tests +skeleton/ standalone application starter (meulah/starter) +``` + +The framework root is a library and is not itself a web application. The starter owns application concerns: the `App\` namespace, environment file, configuration, bootstrap, routes, controllers, views, migrations, public entry point, and root `meulah` launcher. This keeps framework upgrades separate from application code and gives new applications a small conventional structure without making those folders framework requirements. + ## Request lifecycle +Inside an application created from the starter, the request lifecycle remains visible: + ```text public/index.php -> bootstrap.php @@ -32,10 +47,65 @@ use Meulah\Http\Response; $router->get('/', static fn (): Response => Response::html('

Hello

'), 'home'); ``` +The optional final argument names a route. Generate root-relative URLs through the router rather than hard-coding application paths: + +```php +$router->get('/users/{user}', [UserController::class, 'show'], 'users.show'); + +$url = $router->url( + 'users.show', + ['user' => 42], + ['tab' => 'profile'], +); +// /users/42?tab=profile +``` + +Path parameters are required by name and encoded as URL segments. A single parameter cannot contain `/` or `\`; model multi-segment paths as separate route parameters. The separate query array supports nested values and uses RFC 3986 encoding. Unknown route names, missing or extra path parameters, empty values, and duplicate route names fail immediately with a clear exception. Generated URLs are deliberately root-relative; applications that need an absolute URL should prepend their explicitly configured trusted origin. + Unknown paths return `404 Not Found`. A known path requested with an unsupported HTTP method returns `405 Method Not Allowed` with an `Allow` header. `Meulah\Http\Response` is the default implementation of `ResponseInterface`. Routing, middleware, request handlers, and the application kernel depend on the interface, so applications may return another compatible response implementation when needed. +## Dependency injection + +Controller class handlers are constructed through the application's dependency container. Constructor parameters typed as concrete classes are recursively autowired; interfaces require an explicit binding: + +```php +use Meulah\Config\Repository; +use Meulah\Container\Container; + +$container = $app->container(); +$container->bind(UserRepository::class, PdoUserRepository::class); +$container->singleton(Cache::class, function (Container $container): Cache { + return new Cache($container->get(Repository::class)); +}); +$container->instance(Clock::class, $clock); +``` + +`bind()` creates a new instance for each resolution, `singleton()` reuses the first resolved instance, and `instance()` registers an existing object. Factories receive the container and must return an object. + +Controllers can then declare their dependencies normally: + +```php +use Meulah\Http\Request; + +final class UserController +{ + public function __construct(private readonly UserRepository $users) + { + } + + public function show(Request $request, string $user): string + { + return $this->users->find($user)->name; + } +} + +$router->get('/users/{user}', [UserController::class, 'show']); +``` + +Invokable controller class strings are also supported. Meulah deliberately does not guess scalar values, choose among union types, or invent implementations for interfaces. Register those decisions explicitly; unresolved and circular dependencies produce `BindingResolutionException` with the dependency context. + ## Request data Type the first route-handler parameter as `Request` to receive the current request. Route parameters follow it: @@ -213,7 +283,9 @@ php meulah migrate:reset php meulah migrate:fresh ``` -The root `meulah` launcher delegates to the internal `bin/meulah` executable. `migrate` runs only files not recorded in the migration history table. All migrations from one invocation share a batch number, and `migrate:rollback` reverses the most recent batch in reverse filename order. `migrate:reset` rolls back every recorded batch. `migrate:fresh` drops every table—including tables not managed by migrations—and then reruns all migrations. A recorded migration whose file has been removed appears as `Missing` in the status output. +The starter's root `meulah` launcher passes its application root directly to the single framework CLI implementation. The framework also exposes `vendor/bin/meulah`; that entry point honors `MEULAH_APPLICATION_ROOT`, searches upward from the current directory, and then checks its Composer installation relationship. Discovery accepts only projects with the starter's explicit `extra.meulah.application` marker and expected bootstrap, configuration, and route structure. + +`migrate` runs only files not recorded in the migration history table. All migrations from one invocation share a batch number, and `migrate:rollback` reverses the most recent batch in reverse filename order. `migrate:reset` rolls back every recorded batch. `migrate:fresh` drops every table—including tables not managed by migrations—and then reruns all migrations. A recorded migration whose file has been removed appears as `Missing` in the status output. Use `--path=some/directory` to override the configured directory. `DB_MIGRATIONS` and `DB_MIGRATION_TABLE` configure the defaults. Migration SQL remains intentionally explicit, so applications that support multiple database engines should use SQL compatible with each selected engine. @@ -233,9 +305,30 @@ Production responses hide exception details. Development responses include the e ## Installation -1. Copy `.env.example` to `.env` and set local credentials. -2. Run `composer install` from the repository root. -3. Point the web server document root at `public/`, or use the included Apache rewrite rules during local development. +Application developers should use the starter. Once the `0.1` packages are published separately, the normal installation path is: + +```bash +composer create-project meulah/starter my-app +``` + +The starter `0.1` line requires `meulah/framework ^0.1`; it contains no development-branch constraint or monorepo-relative repository. Publishing the framework `0.1` tag and moving `skeleton/` to the separate starter repository are release gates before advertising `create-project` as available. + +Custom skeleton authors and advanced integrations may install the framework directly: + +```bash +composer require meulah/framework:^0.1 +``` + +Framework contributors run `composer install` and `composer test` at this repository root. To exercise a clean application consumer before the packages are published: + +```bash +php tests/create-starter.php ../meulah-test-app +cd ../meulah-test-app +composer install +php meulah --help +``` + +The helper modifies only the disposable copy, injecting a path repository with framework version `0.1.0`. The publication-ready starter manifest remains independent of the monorepo. ## Tests @@ -251,10 +344,10 @@ or: php tests/run.php ``` -## Direction +The GitHub Actions workflow validates and tests the framework on PHP 8.1 and 8.5. It also builds a clean starter consumer on Linux and Windows, installs normal and `--no-dev` dependencies, renders the home route, exercises all three CLI entry paths, and runs migration discovery from a nested directory. -The repository contains only the reusable framework kernel. Authentication, user models, mail delivery, UUID generation, and application views are intentionally not bundled. Applications install optional packages and define those features according to their own needs. +## Application ownership -All framework implementation now lives in the namespaced `src` tree. Application code can organize its own controllers, models, and views without those directories being requirements of the framework. +The framework package contains only reusable kernel behavior. Authentication, user models, mail delivery, UUID generation, and application-specific views are intentionally not bundled. Applications install optional packages and define those features according to their own needs. -The next milestone will provide a separate application skeleton instead of mixing sample application code into the kernel. +All framework implementation lives in the namespaced `src` tree. The starter offers one recommended application layout, but the kernel still depends only on Composer namespaces and explicit bootstrap configuration. diff --git a/bin/meulah b/bin/meulah index fd53ac8..eda0489 100644 --- a/bin/meulah +++ b/bin/meulah @@ -4,16 +4,31 @@ declare(strict_types=1); use Meulah\Console\Application; +use Meulah\Console\ProjectRoot; -$root = dirname(__DIR__); -$autoload = $root . '/vendor/autoload.php'; +$packageRoot = dirname(__DIR__); +$autoloaders = [ + $packageRoot . '/vendor/autoload.php', + dirname($packageRoot, 2) . '/autoload.php', +]; -if (!is_file($autoload)) { +foreach ($autoloaders as $autoload) { + if (is_file($autoload)) { + require_once $autoload; + break; + } +} + +if (!class_exists(Application::class)) { fwrite(STDERR, 'Composer dependencies are missing. Run composer install.' . PHP_EOL); exit(1); } -require $autoload; - -exit((new Application($root))->run($argv)); +try { + $root = ProjectRoot::discover(); +} catch (RuntimeException $exception) { + fwrite(STDERR, 'Error: ' . $exception->getMessage() . PHP_EOL); + exit(1); +} +exit(Application::runFrom($root, $argv)); diff --git a/bootstrap.php b/bootstrap.php deleted file mode 100644 index 7318afc..0000000 --- a/bootstrap.php +++ /dev/null @@ -1,26 +0,0 @@ -bool('app.debug'); - -error_reporting(E_ALL); -ini_set('display_errors', $debug ? '1' : '0'); - -$app = new Application( - new Router(), - $config, - new ExceptionHandler($debug, new ErrorLogLogger()), -); - -return $app; diff --git a/composer.json b/composer.json index 42e9829..52ab5bc 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "meulah/framework", "description": "A small, explicit PHP framework for conventional server-rendered applications.", - "type": "project", + "type": "library", "license": "MIT", "require": { "php": ">=8.1", diff --git a/config/app.php b/config/app.php deleted file mode 100644 index f0044f6..0000000 --- a/config/app.php +++ /dev/null @@ -1,12 +0,0 @@ - $environment, - 'debug' => $environment === 'development', -]; diff --git a/config/database.php b/config/database.php deleted file mode 100644 index b88a8f9..0000000 --- a/config/database.php +++ /dev/null @@ -1,31 +0,0 @@ - $driver, - 'host' => (string) Environment::get('DB_HOST', '127.0.0.1'), - 'port' => $port, - 'database' => (string) Environment::get('DB_NAME', 'meulah'), - 'username' => (string) Environment::get('DB_USER', 'root'), - 'password' => (string) Environment::get('DB_PASS', ''), - 'charset' => 'utf8mb4', - 'path' => $sqlitePath, - 'migrations' => (string) Environment::get('DB_MIGRATIONS', 'database/migrations'), - 'migration_table' => (string) Environment::get('DB_MIGRATION_TABLE', 'meulah_migrations'), -]; diff --git a/config/http.php b/config/http.php deleted file mode 100644 index b9ae33e..0000000 --- a/config/http.php +++ /dev/null @@ -1,9 +0,0 @@ - (int) Environment::get('HTTP_MAX_BODY_SIZE', 10_485_760), -]; diff --git a/meulah b/meulah deleted file mode 100644 index 828f336..0000000 --- a/meulah +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env php - - Options -Multiviews - RewriteEngine On - - # Set the base path dynamically if possible (change folder name to project directory name) - # If you are using a subfolder, uncomment the line below and change 'folder_name' to your folder name - # RewriteBase /folder_name/public - - # If you are using a subdomain or root directory, comment the line above and uncomment the line below - # RewriteBase / - - - # Prevent direct access to PHP files outside index.php - RewriteCond %{REQUEST_URI} !^/index\.php$ - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^(.+)$ index.php?url=$1 [QSA,L] - - - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index e3ce0be..0000000 --- a/public/index.php +++ /dev/null @@ -1,26 +0,0 @@ -router(); -require $root . '/routes/web.php'; - -try { - $request = Request::capture($app->config()->int('http.max_body_size')); - $app->handle($request)->send(); -} catch (Throwable $exception) { - $app->renderException($exception, $request ?? null)->send(); -} diff --git a/routes/web.php b/routes/web.php deleted file mode 100644 index 74b8cdf..0000000 --- a/routes/web.php +++ /dev/null @@ -1,8 +0,0 @@ -config = $config ?? new Repository(); $this->exceptions = $exceptions ?? new ExceptionHandler(false, new ErrorLogLogger()); + $this->container()->instance(self::class, $this); + $this->container()->instance(Repository::class, $this->config); + $this->container()->instance(ExceptionHandler::class, $this->exceptions); } public function router(): Router @@ -41,6 +45,11 @@ public function config(): Repository return $this->config; } + public function container(): Container + { + return $this->router->container(); + } + public function middleware(Middleware ...$middleware): self { array_push($this->middleware, ...$middleware); diff --git a/src/Console/Application.php b/src/Console/Application.php index c63db56..8f482d6 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -18,6 +18,11 @@ public function __construct(private readonly string $root) { } + public static function runFrom(string $applicationRoot, array $arguments): int + { + return (new self(ProjectRoot::explicit($applicationRoot)))->run($arguments); + } + public function run(array $arguments): int { $command = $arguments[1] ?? 'help'; diff --git a/src/Console/ProjectRoot.php b/src/Console/ProjectRoot.php new file mode 100644 index 0000000..da25732 --- /dev/null +++ b/src/Console/ProjectRoot.php @@ -0,0 +1,96 @@ + */ + private array $bindings = []; + + /** @var array */ + private array $shared = []; + + /** @var array */ + private array $instances = []; + + /** @var list */ + private array $resolving = []; + + public function __construct() + { + $this->instances[self::class] = $this; + } + + public function bind(string $abstract, callable|string|null $concrete = null): self + { + return $this->register($abstract, $concrete, false); + } + + public function singleton(string $abstract, callable|string|null $concrete = null): self + { + return $this->register($abstract, $concrete, true); + } + + public function instance(string $abstract, object $instance): self + { + $this->assertCompatible($abstract, $instance, 'Instance'); + $this->instances[$abstract] = $instance; + unset($this->bindings[$abstract], $this->shared[$abstract]); + + return $this; + } + + public function has(string $abstract): bool + { + if (isset($this->instances[$abstract]) || isset($this->bindings[$abstract])) { + return true; + } + + if (!class_exists($abstract)) { + return false; + } + + return (new ReflectionClass($abstract))->isInstantiable(); + } + + public function get(string $abstract): object + { + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + if (in_array($abstract, $this->resolving, true)) { + throw new BindingResolutionException(sprintf( + 'Circular dependency detected: %s.', + implode(' -> ', [...$this->resolving, $abstract]), + )); + } + + $this->resolving[] = $abstract; + + try { + $object = isset($this->bindings[$abstract]) + ? $this->resolveBinding($abstract) + : $this->build($abstract); + + if ($this->shared[$abstract] ?? false) { + $this->instances[$abstract] = $object; + } + + return $object; + } finally { + array_pop($this->resolving); + } + } + + private function register(string $abstract, callable|string|null $concrete, bool $shared): self + { + if ($abstract === '') { + throw new BindingResolutionException('A container binding needs a non-empty type name.'); + } + + $concrete ??= $abstract; + $this->bindings[$abstract] = is_string($concrete) + ? $concrete + : Closure::fromCallable($concrete); + $this->shared[$abstract] = $shared; + unset($this->instances[$abstract]); + + return $this; + } + + private function resolveBinding(string $abstract): object + { + $concrete = $this->bindings[$abstract]; + + try { + $object = is_string($concrete) + ? ($concrete === $abstract ? $this->build($concrete) : $this->get($concrete)) + : $concrete($this); + } catch (BindingResolutionException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new BindingResolutionException( + sprintf("Factory for '%s' failed: %s", $abstract, $exception->getMessage()), + 0, + $exception, + ); + } + + if (!is_object($object)) { + throw new BindingResolutionException(sprintf( + "Factory for '%s' must return an object.", + $abstract, + )); + } + + $this->assertCompatible($abstract, $object, 'Binding'); + + return $object; + } + + private function assertCompatible(string $abstract, object $object, string $source): void + { + if ((interface_exists($abstract) || class_exists($abstract)) && !$object instanceof $abstract) { + throw new BindingResolutionException(sprintf( + "%s for '%s' has incompatible type '%s'.", + $source, + $abstract, + $object::class, + )); + } + } + + private function build(string $className): object + { + if (!class_exists($className)) { + $kind = interface_exists($className) ? 'interface' : 'type'; + throw new BindingResolutionException(sprintf( + "Cannot resolve %s '%s'; register an explicit binding.", + $kind, + $className, + )); + } + + $reflection = new ReflectionClass($className); + + if (!$reflection->isInstantiable()) { + throw new BindingResolutionException(sprintf( + "Cannot instantiate '%s'; register an explicit binding.", + $className, + )); + } + + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return $reflection->newInstance(); + } + + $arguments = array_map( + fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($parameter, $reflection), + $constructor->getParameters(), + ); + + return $reflection->newInstanceArgs($arguments); + } + + private function resolveParameter(ReflectionParameter $parameter, ReflectionClass $class): mixed + { + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $dependency = $this->normalizeTypeName($type->getName(), $class); + + if (!$parameter->isDefaultValueAvailable() + || isset($this->bindings[$dependency]) + || isset($this->instances[$dependency])) { + if ($this->has($dependency)) { + return $this->get($dependency); + } + } + } + + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + if ($type?->allowsNull()) { + return null; + } + + $description = $type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType + ? 'union or intersection type' + : ($type instanceof ReflectionNamedType ? $type->getName() : 'untyped value'); + + throw new BindingResolutionException(sprintf( + "Cannot resolve parameter '$%s' (%s) while constructing '%s'.", + $parameter->getName(), + $description, + $class->getName(), + )); + } + + private function normalizeTypeName(string $name, ReflectionClass $class): string + { + return match ($name) { + 'self', 'static' => $class->getName(), + 'parent' => $class->getParentClass()?->getName() ?? $name, + default => $name, + }; + } +} diff --git a/src/Routing/Router.php b/src/Routing/Router.php index eac3125..84712d1 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -5,6 +5,7 @@ namespace Meulah\Routing; use InvalidArgumentException; +use Meulah\Container\Container; use Meulah\Http\CallableRequestHandler; use Meulah\Http\MiddlewarePipeline; use Meulah\Http\Request; @@ -12,6 +13,7 @@ use Meulah\Http\ResponseInterface; use ReflectionFunction; use ReflectionNamedType; +use Stringable; use UnexpectedValueException; final class Router @@ -19,6 +21,22 @@ final class Router /** @var list */ private array $routes = []; + /** @var array */ + private array $namedRoutes = []; + + private readonly Container $container; + + public function __construct(?Container $container = null) + { + $this->container = $container ?? new Container(); + $this->container->instance(self::class, $this); + } + + public function container(): Container + { + return $this->container; + } + public function get(string $path, callable|array|string $handler, ?string $name = null): Route { return $this->add(['GET', 'HEAD'], $path, $handler, $name); @@ -34,6 +52,54 @@ public function match(array $methods, string $path, callable|array|string $handl return $this->add($methods, $path, $handler, $name); } + public function url(string $name, array $parameters = [], array $query = []): string + { + $route = $this->namedRoutes[$name] ?? null; + + if ($route === null) { + throw new UrlGenerationException(sprintf("Named route '%s' does not exist.", $name)); + } + + $expected = $this->pathParameterNames($route->path); + $provided = array_keys($parameters); + $missing = array_values(array_diff($expected, $provided)); + $extra = array_values(array_diff($provided, $expected)); + + if ($missing !== []) { + throw new UrlGenerationException(sprintf( + "Cannot generate route '%s'; missing path parameters: %s.", + $name, + implode(', ', $missing), + )); + } + + if ($extra !== []) { + throw new UrlGenerationException(sprintf( + "Cannot generate route '%s'; unknown path parameters: %s.", + $name, + implode(', ', $extra), + )); + } + + $path = preg_replace_callback( + '/\{([A-Za-z_][A-Za-z0-9_]*)\}/', + fn (array $match): string => rawurlencode($this->stringifyParameter( + $name, + $match[1], + $parameters[$match[1]], + )), + $route->path, + ); + + if ($path === null) { + throw new UrlGenerationException(sprintf("Could not generate route '%s'.", $name)); + } + + $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + return $queryString === '' ? $path : $path . '?' . $queryString; + } + public function dispatch(Request $request): ResponseInterface { $allowed = []; @@ -83,12 +149,75 @@ private function add(array $methods, string $path, callable|array|string $handle } $path = '/' . trim($path, '/'); + $name = $name === null ? null : trim($name); + + if ($name === '') { + throw new InvalidArgumentException('A named route needs a non-empty name.'); + } + + if ($name !== null && isset($this->namedRoutes[$name])) { + throw new InvalidArgumentException(sprintf("Route name '%s' is already registered.", $name)); + } + + $parameterNames = $this->pathParameterNames($path); + + if (count($parameterNames) !== count(array_unique($parameterNames))) { + throw new InvalidArgumentException('Route path parameter names must be unique.'); + } + $route = new Route($methods, $path === '/' ? '/' : rtrim($path, '/'), $handler, $name); $this->routes[] = $route; + if ($name !== null) { + $this->namedRoutes[$name] = $route; + } + return $route; } + /** @return list */ + private function pathParameterNames(string $path): array + { + preg_match_all('/\{([A-Za-z_][A-Za-z0-9_]*)\}/', $path, $matches); + + return $matches[1]; + } + + private function stringifyParameter(string $route, string $parameter, mixed $value): string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (!is_string($value) && !is_int($value) && !is_float($value) && !$value instanceof Stringable) { + throw new UrlGenerationException(sprintf( + "Path parameter '%s' for route '%s' must be scalar or stringable.", + $parameter, + $route, + )); + } + + $value = (string) $value; + + if ($value === '') { + throw new UrlGenerationException(sprintf( + "Path parameter '%s' for route '%s' cannot be empty.", + $parameter, + $route, + )); + } + + if (str_contains($value, '/') || str_contains($value, '\\')) { + throw new UrlGenerationException(sprintf( + "Path parameter '%s' for route '%s' cannot contain a slash.", + $parameter, + $route, + )); + } + + return $value; + } + private function matchPath(string $routePath, string $requestPath): ?array { $parameterNames = []; @@ -104,13 +233,15 @@ private function matchPath(string $routePath, string $requestPath): ?array array_shift($matches); - return array_combine($parameterNames, array_map('rawurldecode', $matches)) ?: []; + return array_combine($parameterNames, $matches) ?: []; } private function resolveHandler(callable|array|string $handler): callable { if (is_array($handler) && isset($handler[0], $handler[1]) && is_string($handler[0])) { - $handler = [new $handler[0](), $handler[1]]; + $handler = [$this->container->get($handler[0]), $handler[1]]; + } elseif (is_string($handler) && class_exists($handler)) { + $handler = $this->container->get($handler); } if (!is_callable($handler)) { diff --git a/src/Routing/UrlGenerationException.php b/src/Routing/UrlGenerationException.php new file mode 100644 index 0000000..8000fa4 --- /dev/null +++ b/src/Routing/UrlGenerationException.php @@ -0,0 +1,11 @@ +'); +} + +if (file_exists($destination)) { + throw new RuntimeException("Destination already exists: {$destination}"); +} + +if (!mkdir($destination, 0775, true) && !is_dir($destination)) { + throw new RuntimeException("Unable to create destination: {$destination}"); +} + +$files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, +); + +foreach ($files as $file) { + $relative = substr($file->getPathname(), strlen($source) + 1); + $target = $destination . DIRECTORY_SEPARATOR . $relative; + + if ($file->isDir()) { + if (!is_dir($target) && !mkdir($target, 0775, true) && !is_dir($target)) { + throw new RuntimeException("Unable to create directory: {$target}"); + } + + continue; + } + + if (!copy($file->getPathname(), $target)) { + throw new RuntimeException("Unable to copy file: {$relative}"); + } +} + +$composerFile = $destination . '/composer.json'; +$composer = json_decode((string) file_get_contents($composerFile), true, 512, JSON_THROW_ON_ERROR); +$composer['repositories'] ??= []; + +array_unshift($composer['repositories'], [ + 'type' => 'path', + 'url' => realpath(dirname(__DIR__)), + 'options' => [ + 'symlink' => false, + 'versions' => ['meulah/framework' => '0.1.0'], + ], +]); + +file_put_contents( + $composerFile, + json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +); + +fwrite(STDOUT, "Created starter consumer: {$destination}" . PHP_EOL); diff --git a/tests/fixtures/ContainerFixtures.php b/tests/fixtures/ContainerFixtures.php new file mode 100644 index 0000000..b1f0072 --- /dev/null +++ b/tests/fixtures/ContainerFixtures.php @@ -0,0 +1,73 @@ +config->string('app.name') . ': ' . $this->greeting->for($name); + } +} + +final class InvokableGreetingController +{ + public function __construct(private readonly Greeting $greeting) + { + } + + public function __invoke(Request $request): string + { + return $this->greeting->for($request->string('name')); + } +} + +final class ScalarDependencyController +{ + public function __construct(public readonly string $apiKey) + { + } + + public function __invoke(): string + { + return $this->apiKey; + } +} + +final class CircularOne +{ + public function __construct(public readonly CircularTwo $two) + { + } +} + +final class CircularTwo +{ + public function __construct(public readonly CircularOne $one) + { + } +} diff --git a/tests/fixtures/config/app.php b/tests/fixtures/config/app.php new file mode 100644 index 0000000..f6dfa55 --- /dev/null +++ b/tests/fixtures/config/app.php @@ -0,0 +1,9 @@ + 'Meulah Tests', + 'environment' => 'testing', + 'debug' => false, +]; diff --git a/tests/fixtures/config/database.php b/tests/fixtures/config/database.php new file mode 100644 index 0000000..f44aa52 --- /dev/null +++ b/tests/fixtures/config/database.php @@ -0,0 +1,7 @@ + 'mysql', +]; diff --git a/tests/fixtures/config/http.php b/tests/fixtures/config/http.php new file mode 100644 index 0000000..7f64dbb --- /dev/null +++ b/tests/fixtures/config/http.php @@ -0,0 +1,7 @@ + 1024, +]; diff --git a/tests/run.php b/tests/run.php index e91bfbd..73531ba 100644 --- a/tests/run.php +++ b/tests/run.php @@ -3,7 +3,10 @@ declare(strict_types=1); use Meulah\Application; +use Meulah\Container\BindingResolutionException; +use Meulah\Container\Container; use Meulah\Config\Repository; +use Meulah\Console\ProjectRoot; use Meulah\Database\Connection; use Meulah\Database\Migration; use Meulah\Database\MigrationFinder; @@ -25,10 +28,18 @@ use Meulah\Routing\RouteNotFound; use Meulah\Routing\RouteHandlerException; use Meulah\Routing\Router; +use Meulah\Routing\UrlGenerationException; use Meulah\Support\Environment; use Meulah\View\View; +use Tests\Fixtures\CircularOne; +use Tests\Fixtures\FriendlyGreeting; +use Tests\Fixtures\Greeting; +use Tests\Fixtures\GreetingController; +use Tests\Fixtures\InvokableGreetingController; +use Tests\Fixtures\ScalarDependencyController; require __DIR__ . '/bootstrap.php'; +require __DIR__ . '/fixtures/ContainerFixtures.php'; $tests = []; @@ -353,6 +364,82 @@ public function error(Throwable $exception): void $assertSame('en:42', $response->content()); }); +$test('controllers receive constructor dependencies from the container', static function () use ($assertSame): void { + $container = new Container(); + $container->singleton(Greeting::class, FriendlyGreeting::class); + $router = new Router($container); + $application = new Application($router, new Repository(['app' => ['name' => 'Meulah']])); + $router->get('/hello/{name}', [GreetingController::class, 'show']); + + $response = $application->handle(new Request('GET', '/hello/Ada')); + + $assertSame('Meulah: Hello, Ada!', $response->content()); + $assertSame($container, $application->container()); + $assertSame($router, $container->get(Router::class)); +}); + +$test('invokable controller class strings are container resolved', static function () use ($assertSame): void { + $container = new Container(); + $container->bind(Greeting::class, FriendlyGreeting::class); + $router = new Router($container); + $router->post('/hello', InvokableGreetingController::class); + + $response = $router->dispatch(new Request('POST', '/hello', body: ['name' => 'Grace'])); + + $assertSame('Hello, Grace!', $response->content()); +}); + +$test('container distinguishes transient singleton and instance lifetimes', static function () use ($assertSame): void { + $container = new Container(); + $container->bind(FriendlyGreeting::class); + $firstTransient = $container->get(FriendlyGreeting::class); + $secondTransient = $container->get(FriendlyGreeting::class); + $assertSame(false, $firstTransient === $secondTransient); + + $container->singleton(Greeting::class, static fn (Container $container): FriendlyGreeting => new FriendlyGreeting()); + $assertSame($container->get(Greeting::class), $container->get(Greeting::class)); + + $known = new FriendlyGreeting(); + $container->instance(Greeting::class, $known); + $assertSame($known, $container->get(Greeting::class)); +}); + +$test('container rejects incompatible registered instances', static function () use ($assertSame): void { + try { + (new Container())->instance(Greeting::class, new stdClass()); + throw new RuntimeException('Expected incompatible instance rejection.'); + } catch (BindingResolutionException $exception) { + $assertSame( + "Instance for 'Tests\\Fixtures\\Greeting' has incompatible type 'stdClass'.", + $exception->getMessage(), + ); + } +}); + +$test('container rejects unresolved scalar dependencies', static function () use ($assertSame): void { + try { + (new Container())->get(ScalarDependencyController::class); + throw new RuntimeException('Expected scalar dependency rejection.'); + } catch (BindingResolutionException $exception) { + $assertSame( + "Cannot resolve parameter '\$apiKey' (string) while constructing 'Tests\\Fixtures\\ScalarDependencyController'.", + $exception->getMessage(), + ); + } +}); + +$test('container reports circular dependency chains', static function () use ($assertSame): void { + try { + (new Container())->get(CircularOne::class); + throw new RuntimeException('Expected circular dependency rejection.'); + } catch (BindingResolutionException $exception) { + $assertSame( + 'Circular dependency detected: Tests\\Fixtures\\CircularOne -> Tests\\Fixtures\\CircularTwo -> Tests\\Fixtures\\CircularOne.', + $exception->getMessage(), + ); + } +}); + $test('route handler injection is strict and reports argument mismatches', static function () use ($assertSame): void { $router = new Router(); $router->get('/users/{user}', static fn ($request, string $user): string => $user); @@ -374,6 +461,114 @@ public function error(Throwable $exception): void $assertSame('ok', $response->content()); }); +$test('named routes generate encoded paths and RFC 3986 query strings', static function () use ($assertSame): void { + $router = new Router(); + $router->get( + '/teams/{team}/users/{user}', + static fn (string $team, string $user): string => $team . ':' . $user, + 'users.show', + ); + + $url = $router->url( + 'users.show', + ['team' => 'Core Team', 'user' => 'Ada Lovelace'], + ['tab' => 'account settings', 'filter' => ['active' => true]], + ); + + $assertSame( + '/teams/Core%20Team/users/Ada%20Lovelace?tab=account%20settings&filter%5Bactive%5D=1', + $url, + ); +}); + +$test('generated path values are decoded exactly once during dispatch', static function () use ($assertSame): void { + $router = new Router(); + $router->get('/references/{reference}', static fn (string $reference): string => $reference, 'references.show'); + $path = $router->url('references.show', ['reference' => '%2F']); + + $assertSame('/references/%252F', $path); + $assertSame('%2F', $router->dispatch(new Request('GET', $path))->content()); +}); + +$test('named route generation requires the exact path parameter set', static function () use ($assertSame): void { + $router = new Router(); + $router->get('/articles/{article}', static fn (): string => 'article', 'articles.show'); + + try { + $router->url('articles.show'); + throw new RuntimeException('Expected missing route parameter rejection.'); + } catch (UrlGenerationException $exception) { + $assertSame( + "Cannot generate route 'articles.show'; missing path parameters: article.", + $exception->getMessage(), + ); + } + + try { + $router->url('articles.show', ['article' => 42, 'page' => 2]); + throw new RuntimeException('Expected unknown route parameter rejection.'); + } catch (UrlGenerationException $exception) { + $assertSame( + "Cannot generate route 'articles.show'; unknown path parameters: page.", + $exception->getMessage(), + ); + } +}); + +$test('named route generation rejects unknown routes and duplicate names', static function () use ($assertSame): void { + $router = new Router(); + $router->get('/first', static fn (): string => 'first', 'shared.name'); + + try { + $router->url('missing.route'); + throw new RuntimeException('Expected unknown named route rejection.'); + } catch (UrlGenerationException $exception) { + $assertSame("Named route 'missing.route' does not exist.", $exception->getMessage()); + } + + try { + $router->get('/second', static fn (): string => 'second', 'shared.name'); + throw new RuntimeException('Expected duplicate route name rejection.'); + } catch (InvalidArgumentException $exception) { + $assertSame("Route name 'shared.name' is already registered.", $exception->getMessage()); + } +}); + +$test('named route path parameters cannot be empty or non-stringable', static function () use ($assertSame): void { + $router = new Router(); + $router->get('/files/{file}', static fn (): string => 'file', 'files.show'); + + foreach (['' => 'cannot be empty', 'array' => 'must be scalar or stringable'] as $kind => $message) { + $value = $kind === '' ? '' : ['not', 'valid']; + + try { + $router->url('files.show', ['file' => $value]); + throw new RuntimeException('Expected invalid path parameter rejection.'); + } catch (UrlGenerationException $exception) { + $assertSame(true, str_contains($exception->getMessage(), $message)); + } + } + + try { + $router->url('files.show', ['file' => 'reports/annual.pdf']); + throw new RuntimeException('Expected slash-containing path parameter rejection.'); + } catch (UrlGenerationException $exception) { + $assertSame( + "Path parameter 'file' for route 'files.show' cannot contain a slash.", + $exception->getMessage(), + ); + } +}); + +$test('route paths reject duplicate parameter names', static function () use ($assertSame): void { + try { + (new Router())->get('/compare/{item}/{item}', static fn (): string => 'compare'); + throw new RuntimeException('Expected duplicate path parameter rejection.'); + } catch (InvalidArgumentException $exception) { + $assertSame('Route path parameter names must be unique.', $exception->getMessage()); + } +}); + $test('routing accepts custom response interface implementations', static function () use ($assertSame): void { $custom = new class implements ResponseInterface { public function status(): int @@ -596,13 +791,93 @@ public function process(Request $request, RequestHandler $next): Response $assertSame('fallback', $config->get('missing', 'fallback')); }); -$test('configuration loads root configuration files', static function () use ($assertSame): void { - $config = Repository::load(dirname(__DIR__) . '/config'); +$test('configuration loads application configuration files', static function () use ($assertSame): void { + $config = Repository::load(__DIR__ . '/fixtures/config'); $assertSame(true, $config->has('app.environment')); $assertSame('mysql', $config->string('database.driver')); }); +$test('project root discovery walks up from an application subdirectory', static function () use ($assertSame): void { + $skeleton = realpath(dirname(__DIR__) . '/skeleton'); + + if ($skeleton === false) { + throw new RuntimeException('Starter skeleton is missing.'); + } + + $assertSame($skeleton, ProjectRoot::discover($skeleton . '/public')); + $assertSame($skeleton, ProjectRoot::discover($skeleton . '/database/migrations')); +}); + +$test('project root discovery honors the explicit environment root first', static function () use ($assertSame): void { + $key = 'MEULAH_APPLICATION_ROOT'; + $original = $_ENV[$key] ?? null; + $existed = array_key_exists($key, $_ENV); + $skeleton = realpath(dirname(__DIR__) . '/skeleton'); + + if ($skeleton === false) { + throw new RuntimeException('Starter skeleton is missing.'); + } + + try { + $_ENV[$key] = $skeleton; + $assertSame($skeleton, ProjectRoot::discover(dirname(__DIR__))); + } finally { + if ($existed) { + $_ENV[$key] = $original; + } else { + unset($_ENV[$key]); + } + } +}); + +$test('project root discovery rejects unmarked Composer projects', static function () use ($assertSame): void { + try { + ProjectRoot::explicit(dirname(__DIR__)); + throw new RuntimeException('Expected unmarked project rejection.'); + } catch (RuntimeException $exception) { + $assertSame( + true, + str_starts_with($exception->getMessage(), 'Directory is not a marked Meulah application:'), + ); + } +}); + +$test('starter skeleton boots its controller route and view', static function () use ($assertSame): void { + $root = dirname(__DIR__) . '/skeleton'; + $autoload = static function (string $className) use ($root): void { + $prefix = 'App\\'; + + if (!str_starts_with($className, $prefix)) { + return; + } + + $relativeClass = substr($className, strlen($prefix)); + $file = $root . '/app/' . str_replace('\\', '/', $relativeClass) . '.php'; + + if (is_file($file)) { + require_once $file; + } + }; + + spl_autoload_register($autoload); + + try { + /** @var Application $application */ + $application = require $root . '/bootstrap.php'; + $router = $application->router(); + require $root . '/routes/web.php'; + + $response = $application->handle(new Request('GET', '/')); + + $assertSame(200, $response->status()); + $assertSame(true, str_contains($response->content(), 'Your Meulah application is ready.')); + $assertSame('/', $router->url('home')); + } finally { + spl_autoload_unregister($autoload); + } +}); + $test('environment reads server values before defaults', static function () use ($assertSame): void { $key = 'MEULAH_TEST_ENVIRONMENT_VALUE'; $original = $_SERVER[$key] ?? null;