diff --git a/.gitattributes b/.gitattributes index 9d20d35..566c499 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,10 @@ .github/ export-ignore .gitignore export-ignore CLAUDE.md export-ignore +ncs.* export-ignore +phpstan*.neon export-ignore +tests/ export-ignore +*.php* diff=php *.sh text eol=lf +mcp-inspector text eol=lf diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml new file mode 100644 index 0000000..4b2528f --- /dev/null +++ b/.github/workflows/coding-style.yml @@ -0,0 +1,31 @@ +name: Coding Style + +on: [push, pull_request] + +jobs: + nette_cc: + name: Nette Code Checker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + - run: composer create-project nette/code-checker temp/code-checker ^3 --no-progress + - run: php temp/code-checker/code-checker --strict-types --no-progress + + + nette_cs: + name: Nette Coding Standard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + - run: composer create-project nette/coding-standard temp/coding-standard ^3 --no-progress + - run: php temp/coding-standard/ecs check diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..ae9ddb2 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,21 @@ +name: Static Analysis (only informative) + +on: + push: + branches: + - master + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer phpstan + continue-on-error: true # is only informative diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..859398b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,60 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + + fail-fast: false + + name: PHP ${{ matrix.php }} tests + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer tester + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: output-${{ matrix.php }} + path: tests/**/output + + + lowest_dependencies: + name: Lowest Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable + - run: composer tester + + + code_coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer tester -- -p phpdbg --coverage ./coverage.xml --coverage-src ./src + - run: wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar + - env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: php php-coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/bin/mcp-inspector b/bin/mcp-inspector new file mode 100644 index 0000000..7a257f3 --- /dev/null +++ b/bin/mcp-inspector @@ -0,0 +1,22 @@ +#!/usr/bin/env php +runCli(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..629a360 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "nette/mcp-inspector", + "description": "🔭 MCP server for Nette application introspection", + "keywords": ["nette", "mcp", "model-context-protocol", "introspection", "di", "dependency-injection", "ai", "llm"], + "homepage": "https://nette.org", + "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + } + ], + "require": { + "php": "8.2 - 8.5", + "mcp/sdk": "^0.2", + "nette/di": "^3.2", + "nette/bootstrap": "^3.2", + "nette/neon": "^3.4", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1" + }, + "require-dev": { + "nette/application": "^3.2", + "nette/database": "^3.2", + "nette/routing": "^3.1", + "nette/tester": "^2.5.2", + "tracy/tracy": "^2.11", + "mockery/mockery": "^1.6@stable", + "phpstan/phpstan-nette": "^2.0@stable" + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + } + }, + "bin": [ + "bin/mcp-inspector" + ], + "scripts": { + "phpstan": "phpstan analyse", + "tester": "tester tests -s -C" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } + } +} diff --git a/license.md b/license.md new file mode 100644 index 0000000..cf741bd --- /dev/null +++ b/license.md @@ -0,0 +1,60 @@ +Licenses +======== + +Good news! You may use Nette Framework under the terms of either +the New BSD License or the GNU General Public License (GPL) version 2 or 3. + +The BSD License is recommended for most projects. It is easy to understand and it +places almost no restrictions on what you can do with the framework. If the GPL +fits better to your project, you can use the framework under this license. + +You don't have to notify anyone which license you are using. You can freely +use Nette Framework in commercial projects as long as the copyright header +remains intact. + +Please be advised that the name "Nette Framework" is a protected trademark and its +usage has some limitations. So please do not use word "Nette" in the name of your +project or top-level domain, and choose a name that stands on its own merits. +If your stuff is good, it will not take long to establish a reputation for yourselves. + + +New BSD License +--------------- + +Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of "Nette Framework" nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as is" and +any express or implied warranties, including, but not limited to, the implied +warranties of merchantability and fitness for a particular purpose are +disclaimed. In no event shall the copyright owner or contributors be liable for +any direct, indirect, incidental, special, exemplary, or consequential damages +(including, but not limited to, procurement of substitute goods or services; +loss of use, data, or profits; or business interruption) however caused and on +any theory of liability, whether in contract, strict liability, or tort +(including negligence or otherwise) arising in any way out of the use of this +software, even if advised of the possibility of such damage. + + +GNU General Public License +-------------------------- + +GPL licenses are very very long, so instead of including them here we offer +you URLs with full text: + +- [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) +- [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2cad2f1 --- /dev/null +++ b/readme.md @@ -0,0 +1,126 @@ +# Nette MCP Inspector + +MCP (Model Context Protocol) server for Nette application introspection. Allows AI assistants to inspect your Nette application's DI container, database schema, routing, and more. + +image + +## Installation + +With [Claude Code](https://claude.com/product/claude-code) and the [Nette plugin](https://github.com/nette/claude-code): + +``` +/install-mcp-inspector +``` + +Or manually: + +```bash +composer require nette/mcp-inspector +``` + +After installation, restart Claude Code session to activate the MCP server. + +## Available Tools + +### DI Container + +| Tool | Description | +|------|-------------| +| `di_get_services` | List all registered services with types and autowiring info | +| `di_get_service` | Get details of a specific service (factory, setup calls, tags) | + +### Database + +| Tool | Description | +|------|-------------| +| `db_get_tables` | List all database tables | +| `db_get_columns` | Get columns of a specific table (types, nullable, primary key, foreign keys) | +| `db_get_relationships` | Get foreign key relationships between all tables (belongsTo, hasMany) | + +### Router + +| Tool | Description | +|------|-------------| +| `router_get_routes` | List all registered routes with masks and defaults | +| `router_match_url` | Match URL to presenter/action (e.g., "/article/123") | +| `router_generate_url` | Generate URL for presenter/action (e.g., "Article:show") | + +## Configuration + +Create `mcp-config.neon` in your project root (optional): + +```neon +# Path to Bootstrap file (defaults to app/Bootstrap.php) +bootstrap: app/Bootstrap.php + +# Bootstrap class name (defaults to App\Bootstrap) +bootstrapClass: App\Bootstrap + +# Custom toolkits +toolkits: + - App\Mcp\MyCustomToolkit +``` + +## Creating Custom Toolkits + +```php +use Mcp\Capability\Attribute\McpTool; +use Nette\McpInspector\Toolkit; +use Nette\McpInspector\Bridge\BootstrapBridge; + +class MyToolkit implements Toolkit +{ + public function __construct( + private BootstrapBridge $bridge, + ) {} + + /** + * Tool description from PHPDoc. + * @param string $param Parameter description + */ + #[McpTool(name: 'my_tool')] + public function myMethod(string $param): array + { + return ['result' => 'data']; + } +} +``` + +Register in `mcp-config.neon`: + +```neon +toolkits: + - App\Mcp\MyToolkit +``` + +## Standalone Usage + +### CLI Mode + +```bash +php vendor/bin/mcp-inspector +``` + +### HTTP Mode + +Copy `www/mcp-inspector.php` to your web root for HTTP-based MCP access. + +### Manual MCP Configuration + +If not using the Nette plugin, add to your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "nette-inspector": { + "command": "php", + "args": ["vendor/bin/mcp-inspector"] + } + } +} +``` + +## Requirements + +- PHP 8.2+ +- Nette Framework 3.2+ diff --git a/src/McpInspector/Bridge/BootstrapBridge.php b/src/McpInspector/Bridge/BootstrapBridge.php new file mode 100644 index 0000000..5b03d49 --- /dev/null +++ b/src/McpInspector/Bridge/BootstrapBridge.php @@ -0,0 +1,66 @@ +builder === null) { + $this->compile(); + } + + return $this->builder; + } + + + public function getContainer(): Container + { + if ($this->container === null) { + $this->container = $this->createContainer(); + } + + return $this->container; + } + + + private function compile(): void + { + require_once getcwd() . '/' . $this->bootstrapPath; + + $configurator = ($this->bootstrapClass)::boot(); + $configurator->onCompile[] = function ($cfg, Compiler $compiler) { + $compiler->processExtensions(); + $this->builder = $compiler->getContainerBuilder(); + }; + $configurator->loadContainer(); + } + + + private function createContainer(): Container + { + require_once getcwd() . '/' . $this->bootstrapPath; + return ($this->bootstrapClass)::boot()->createContainer(); + } +} diff --git a/src/McpInspector/Server.php b/src/McpInspector/Server.php new file mode 100644 index 0000000..9d2e0b3 --- /dev/null +++ b/src/McpInspector/Server.php @@ -0,0 +1,67 @@ +toolkits[] = $toolkit; + return $this; + } + + + /** + * Run with stdio transport (CLI). + */ + public function runCli(): void + { + $this->build()->run(new StdioTransport); + } + + + /** + * Run with HTTP transport, returns PSR-7 Response. + */ + public function runHttp( + ServerRequestInterface $request, + ResponseFactoryInterface $responseFactory, + StreamFactoryInterface $streamFactory, + ): ResponseInterface + { + $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); + return $this->build()->run($transport); + } + + + private function build(): McpServer + { + $builder = McpServer::builder() + ->setServerInfo('Nette MCP Inspector', '1.0.0'); + + // Register toolkit instances - SDK discovers #[McpTool] methods via reflection + foreach ($this->toolkits as $toolkit) { + $builder->addToolsFromObject($toolkit); + } + + return $builder->build(); + } +} diff --git a/src/McpInspector/ServerFactory.php b/src/McpInspector/ServerFactory.php new file mode 100644 index 0000000..1021a0b --- /dev/null +++ b/src/McpInspector/ServerFactory.php @@ -0,0 +1,48 @@ +addToolkit($toolkit); + } + + // Custom user toolkits + foreach ($config['toolkits'] ?? [] as $class) { + $server->addToolkit(new $class($bridge)); + } + + return $server; + } +} diff --git a/src/McpInspector/Toolkit.php b/src/McpInspector/Toolkit.php new file mode 100644 index 0000000..48302a1 --- /dev/null +++ b/src/McpInspector/Toolkit.php @@ -0,0 +1,14 @@ +getContainerBuilder()); + } + + + public function __construct( + private ContainerBuilder $builder, + ) { + } + + + /** + * List all registered DI container services with their types and autowiring info. + * @param string|null $filter Optional filter by service name or type (case-sensitive substring match) + */ + #[McpTool( + name: 'di_get_services', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getServices(?string $filter = null): array + { + $services = []; + + foreach ($this->builder->getDefinitions() as $name => $definition) { + $info = $this->extractBasicInfo($name, $definition); + + if ($filter === null + || str_contains($name, $filter) + || str_contains($info['type'] ?? '', $filter) + ) { + $services[] = $info; + } + } + + return [ + 'services' => $services, + 'count' => count($services), + ]; + } + + + /** + * Get detailed information about a specific service including factory, setup calls, and tags. + * @param string $name The exact service name as registered in the DI container + */ + #[McpTool( + name: 'di_get_service', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getService(string $name): array + { + if (!$this->builder->hasDefinition($name)) { + return ['error' => "Service '$name' not found"]; + } + + $definition = $this->builder->getDefinition($name); + return $this->extractFullDetails($name, $definition); + } + + + private function extractBasicInfo(string $name, Definition $definition): array + { + return [ + 'name' => $name, + 'type' => $definition->getType(), + 'autowired' => $definition->getAutowired(), + 'tags' => $definition->getTags(), + ]; + } + + + private function extractFullDetails(string $name, Definition $definition): array + { + $info = $this->extractBasicInfo($name, $definition); + + // Add kind + $info['kind'] = match (true) { + $definition instanceof ServiceDefinition => 'service', + $definition instanceof FactoryDefinition => 'factory', + default => $definition::class, + }; + + // Add factory info for ServiceDefinition + if ($definition instanceof ServiceDefinition) { + $creator = $definition->getCreator(); + if ($creator) { + $entity = $creator->getEntity(); + $info['factory'] = $this->formatEntity($entity); + } + + // Add setup calls + $setup = $definition->getSetup(); + if ($setup) { + $info['setup'] = array_map( + fn($s) => $this->formatEntity($s->getEntity()), + $setup, + ); + } + } + + // Add result type for FactoryDefinition + if ($definition instanceof FactoryDefinition) { + $info['resultType'] = $definition->getResultType(); + $info['interface'] = $definition->getImplement(); + } + + return $info; + } + + + private function formatEntity(mixed $entity): ?string + { + if (is_string($entity)) { + return $entity; + } + + if (is_array($entity) && count($entity) === 2) { + [$class, $method] = $entity; + if (is_string($class)) { + return "$class::$method"; + } + } + + return null; + } +} diff --git a/src/McpInspector/Toolkits/DatabaseToolkit.php b/src/McpInspector/Toolkits/DatabaseToolkit.php new file mode 100644 index 0000000..e0e51d6 --- /dev/null +++ b/src/McpInspector/Toolkits/DatabaseToolkit.php @@ -0,0 +1,181 @@ +getContainer(); + if ($container->hasService('database.default.explorer')) { + return new self($container->getService('database.default.explorer')); + } + + try { + return new self($container->getByType(Explorer::class)); + } catch (\Throwable) { + return null; + } + } + + + public function __construct( + private Explorer $explorer, + ) { + } + + + /** + * List all database tables and views with their names. + */ + #[McpTool( + name: 'db_get_tables', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getTables(): array + { + $structure = $this->explorer->getStructure(); + $tables = $structure->getTables(); + + $result = []; + foreach ($tables as $table) { + $result[] = [ + 'name' => $table['name'], + 'view' => $table['view'] ?? false, + ]; + } + + return [ + 'tables' => $result, + 'count' => count($result), + ]; + } + + + /** + * Get columns of a specific table with types, nullable, defaults, and foreign keys. + * @param string $table The exact table name as it exists in the database + */ + #[McpTool( + name: 'db_get_columns', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getColumns(string $table): array + { + $structure = $this->explorer->getStructure(); + + try { + $columns = $structure->getColumns($table); + } catch (\Throwable $e) { + return ['error' => "Table '$table' not found: " . $e->getMessage()]; + } + + $primaryKey = $structure->getPrimaryKey($table); + $belongsTo = $structure->getBelongsToReference($table) ?? []; + + $result = []; + foreach ($columns as $column) { + $info = [ + 'name' => $column['name'], + 'type' => $column['nativetype'] ?? $column['type'] ?? 'unknown', + 'nullable' => $column['nullable'] ?? false, + 'default' => $column['default'] ?? null, + 'primary' => $column['name'] === $primaryKey || (is_array($primaryKey) && in_array($column['name'], $primaryKey, true)), + 'autoincrement' => $column['autoincrement'] ?? false, + ]; + + // Add foreign key info + if (isset($belongsTo[$column['name']])) { + $info['foreignKey'] = [ + 'table' => $belongsTo[$column['name']][0], + 'column' => $belongsTo[$column['name']][1] ?? $primaryKey, + ]; + } + + $result[] = $info; + } + + return [ + 'table' => $table, + 'columns' => $result, + 'primaryKey' => $primaryKey, + ]; + } + + + /** + * Get all foreign key relationships between tables (belongsTo and hasMany). + */ + #[McpTool( + name: 'db_get_relationships', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getRelationships(): array + { + $structure = $this->explorer->getStructure(); + $tables = $structure->getTables(); + + $relationships = []; + foreach ($tables as $table) { + $tableName = $table['name']; + + // BelongsTo (foreign keys from this table) + $belongsTo = $structure->getBelongsToReference($tableName); + if ($belongsTo) { + foreach ($belongsTo as $column => $ref) { + $relationships[] = [ + 'type' => 'belongsTo', + 'from' => ['table' => $tableName, 'column' => $column], + 'to' => ['table' => $ref[0], 'column' => $ref[1] ?? null], + ]; + } + } + + // HasMany (reverse relationships) + $hasMany = $structure->getHasManyReference($tableName); + if ($hasMany) { + foreach ($hasMany as $targetTable => $columns) { + foreach ((array) $columns as $column) { + $relationships[] = [ + 'type' => 'hasMany', + 'from' => ['table' => $tableName], + 'to' => ['table' => $targetTable, 'column' => $column], + ]; + } + } + } + } + + return [ + 'relationships' => $relationships, + 'count' => count($relationships), + ]; + } + + + /** + * Generate a suggested ActiveRow entity class with phpDoc annotations for a table. + * @param string $table The exact table name to generate entity for + */ + #[McpTool( + name: 'db_suggest_entity', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function suggestEntity(string $table): array + { + // TODO: Implement entity generation + return ['error' => 'Not yet implemented']; + } +} diff --git a/src/McpInspector/Toolkits/RouterToolkit.php b/src/McpInspector/Toolkits/RouterToolkit.php new file mode 100644 index 0000000..22a3dd1 --- /dev/null +++ b/src/McpInspector/Toolkits/RouterToolkit.php @@ -0,0 +1,189 @@ +getContainer(); + + try { + $router = $container->getByType(Router::class); + } catch (\Throwable) { + return null; + } + + try { + $linkGenerator = $container->getByType(LinkGenerator::class); + } catch (\Throwable) { + $linkGenerator = null; + } + + return new self($router, $linkGenerator); + } + + + public function __construct( + private Router $router, + private ?object $linkGenerator = null, + ) { + } + + + /** + * List all registered routes with their masks, defaults, and module prefixes. + */ + #[McpTool( + name: 'router_get_routes', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function getRoutes(): array + { + $routes = $this->extractRoutes($this->router); + + return [ + 'routes' => $routes, + 'count' => count($routes), + ]; + } + + + /** + * Match URL to presenter/action and extract parameters. + * @param string $url URL to match (e.g., "/article/123" or "https://example.com/article/123") + */ + #[McpTool( + name: 'router_match_url', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function matchUrl(string $url): array + { + // Normalize URL + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'http://localhost' . (str_starts_with($url, '/') ? '' : '/') . $url; + } + + try { + $urlScript = new UrlScript($url); + $httpRequest = new Request($urlScript); + $params = $this->router->match($httpRequest); + + if ($params === null) { + return [ + 'matched' => false, + 'url' => $url, + 'error' => 'No route matches this URL', + ]; + } + + $presenter = $params['presenter'] ?? null; + $action = $params['action'] ?? 'default'; + unset($params['presenter'], $params['action']); + + return [ + 'matched' => true, + 'url' => $url, + 'presenter' => $presenter, + 'action' => $action, + 'params' => $params, + ]; + } catch (\Throwable $e) { + return [ + 'matched' => false, + 'url' => $url, + 'error' => $e->getMessage(), + ]; + } + } + + + /** + * Generate URL for presenter/action using LinkGenerator. + * @param string $destination Presenter:action notation (e.g., "Article:show" or ":Front:Article:show") + * @param array|null $params Optional parameters for the URL (e.g., ["id" => 123]) + */ + #[McpTool( + name: 'router_generate_url', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function generateUrl(string $destination, ?array $params = null): array + { + if ($this->linkGenerator === null) { + return ['error' => 'LinkGenerator not configured']; + } + + try { + $url = $this->linkGenerator->link($destination, $params ?? []); + + return [ + 'destination' => $destination, + 'params' => $params ?? [], + 'url' => $url, + ]; + } catch (\Throwable $e) { + return [ + 'destination' => $destination, + 'params' => $params ?? [], + 'error' => $e->getMessage(), + ]; + } + } + + + private function extractRoutes(Router $router, string $prefix = ''): array + { + $routes = []; + + if ($router instanceof RouteList) { + $module = $router->getModule(); + $newPrefix = $module ? $prefix . $module . ':' : $prefix; + + foreach ($router as $route) { + $routes = array_merge($routes, $this->extractRoutes($route, $newPrefix)); + } + } else { + // Single route - try to extract info + $info = [ + 'type' => $router::class, + 'prefix' => $prefix ?: null, + ]; + + // Try to get mask from Route + if (method_exists($router, 'getMask')) { + $info['mask'] = $router->getMask(); + } + + // Try to get defaults + if (method_exists($router, 'getDefaults')) { + $defaults = $router->getDefaults(); + if (isset($defaults['presenter'])) { + $info['presenter'] = $prefix . $defaults['presenter']; + } + if (isset($defaults['action'])) { + $info['action'] = $defaults['action']; + } + } + + $routes[] = $info; + } + + return $routes; + } +} diff --git a/tests/.coveralls.yml b/tests/.coveralls.yml new file mode 100644 index 0000000..8450382 --- /dev/null +++ b/tests/.coveralls.yml @@ -0,0 +1,4 @@ +# for php-coveralls +service_name: github-actions +coverage_clover: coverage.xml +json_path: coverage.json diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..a7ffcfd --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,3 @@ +/*/output +/test.log +/tmp diff --git a/tests/Server/Server.phpt b/tests/Server/Server.phpt new file mode 100644 index 0000000..45ee6e3 --- /dev/null +++ b/tests/Server/Server.phpt @@ -0,0 +1,30 @@ +addToolkit($toolkit)); +}); + + +test('multiple toolkits can be added', function () { + $server = new Server; + $server->addToolkit(new DummyToolkit); + $server->addToolkit(new DummyToolkit); + // No exception thrown = success + Assert::true(true); +}); diff --git a/tests/Toolkits/DIToolkit.phpt b/tests/Toolkits/DIToolkit.phpt new file mode 100644 index 0000000..95916b7 --- /dev/null +++ b/tests/Toolkits/DIToolkit.phpt @@ -0,0 +1,91 @@ +addDefinition('foo') + ->setType('stdClass'); + $builder->addDefinition('bar') + ->setType('ArrayObject'); + + $toolkit = new DIToolkit($builder); + $result = $toolkit->getServices(); + + // ContainerBuilder adds 'container' service automatically + Assert::same(3, $result['count']); + Assert::count(3, $result['services']); + + // Filter out internal 'container' service for assertions + $userServices = array_values(array_filter( + $result['services'], + fn($s) => $s['name'] !== 'container', + )); + Assert::same('foo', $userServices[0]['name']); + Assert::same('stdClass', $userServices[0]['type']); + Assert::same('bar', $userServices[1]['name']); + Assert::same('ArrayObject', $userServices[1]['type']); +}); + + +test('getServices filters by name', function () { + $builder = new ContainerBuilder; + $builder->addDefinition('database.connection') + ->setType('PDO'); + $builder->addDefinition('cache.storage') + ->setType('stdClass'); + + $toolkit = new DIToolkit($builder); + $result = $toolkit->getServices('database'); + + Assert::same(1, $result['count']); + Assert::same('database.connection', $result['services'][0]['name']); +}); + + +test('getServices filters by type', function () { + $builder = new ContainerBuilder; + $builder->addDefinition('foo') + ->setType('stdClass'); + $builder->addDefinition('bar') + ->setType('ArrayObject'); + + $toolkit = new DIToolkit($builder); + $result = $toolkit->getServices('Array'); + + Assert::same(1, $result['count']); + Assert::same('bar', $result['services'][0]['name']); +}); + + +test('getService returns service details', function () { + $builder = new ContainerBuilder; + $builder->addDefinition('myService') + ->setType('stdClass') + ->addTag('mytag', 'value'); + + $toolkit = new DIToolkit($builder); + $result = $toolkit->getService('myService'); + + Assert::same('myService', $result['name']); + Assert::same('stdClass', $result['type']); + Assert::same('service', $result['kind']); + Assert::same(['mytag' => 'value'], $result['tags']); +}); + + +test('getService returns error for non-existent service', function () { + $builder = new ContainerBuilder; + + $toolkit = new DIToolkit($builder); + $result = $toolkit->getService('nonexistent'); + + Assert::same("Service 'nonexistent' not found", $result['error']); +}); diff --git a/tests/Toolkits/DatabaseToolkit.phpt b/tests/Toolkits/DatabaseToolkit.phpt new file mode 100644 index 0000000..ff50b28 --- /dev/null +++ b/tests/Toolkits/DatabaseToolkit.phpt @@ -0,0 +1,138 @@ +shouldReceive('hasService')->with('database.default.explorer')->andReturn(false); + $container->shouldReceive('getByType')->with(Explorer::class)->andThrow(new Exception('Not found')); + + $bridge = Mockery::mock(BootstrapBridge::class); + $bridge->shouldReceive('getContainer')->andReturn($container); + + $toolkit = DatabaseToolkit::tryCreate($bridge); + + Assert::null($toolkit); +}); + + +test('getTables returns list of tables', function () { + $structure = Mockery::mock(IStructure::class); + $structure->shouldReceive('getTables')->andReturn([ + ['name' => 'users', 'view' => false], + ['name' => 'articles', 'view' => false], + ['name' => 'user_stats', 'view' => true], + ]); + + $explorer = Mockery::mock(Explorer::class); + $explorer->shouldReceive('getStructure')->andReturn($structure); + + $toolkit = new DatabaseToolkit($explorer); + $result = $toolkit->getTables(); + + Assert::same(3, $result['count']); + Assert::count(3, $result['tables']); + Assert::same('users', $result['tables'][0]['name']); + Assert::false($result['tables'][0]['view']); + Assert::true($result['tables'][2]['view']); +}); + + +test('getColumns returns column info with foreign keys', function () { + $structure = Mockery::mock(IStructure::class); + $structure->shouldReceive('getColumns')->with('articles')->andReturn([ + ['name' => 'id', 'nativetype' => 'int', 'nullable' => false, 'default' => null, 'autoincrement' => true], + ['name' => 'user_id', 'nativetype' => 'int', 'nullable' => false, 'default' => null, 'autoincrement' => false], + ['name' => 'title', 'nativetype' => 'varchar', 'nullable' => false, 'default' => null, 'autoincrement' => false], + ]); + $structure->shouldReceive('getPrimaryKey')->with('articles')->andReturn('id'); + $structure->shouldReceive('getBelongsToReference')->with('articles')->andReturn([ + 'user_id' => ['users', 'id'], + ]); + + $explorer = Mockery::mock(Explorer::class); + $explorer->shouldReceive('getStructure')->andReturn($structure); + + $toolkit = new DatabaseToolkit($explorer); + $result = $toolkit->getColumns('articles'); + + Assert::same('articles', $result['table']); + Assert::same('id', $result['primaryKey']); + Assert::count(3, $result['columns']); + + // Check id column + Assert::same('id', $result['columns'][0]['name']); + Assert::true($result['columns'][0]['primary']); + Assert::true($result['columns'][0]['autoincrement']); + + // Check user_id column with foreign key + Assert::same('user_id', $result['columns'][1]['name']); + Assert::same(['table' => 'users', 'column' => 'id'], $result['columns'][1]['foreignKey']); +}); + + +test('getColumns returns error for non-existent table', function () { + $structure = Mockery::mock(IStructure::class); + $structure->shouldReceive('getColumns')->with('nonexistent')->andThrow(new Exception('Table not found')); + + $explorer = Mockery::mock(Explorer::class); + $explorer->shouldReceive('getStructure')->andReturn($structure); + + $toolkit = new DatabaseToolkit($explorer); + $result = $toolkit->getColumns('nonexistent'); + + Assert::contains("Table 'nonexistent' not found", $result['error']); +}); + + +test('getRelationships returns all relationships', function () { + $structure = Mockery::mock(IStructure::class); + $structure->shouldReceive('getTables')->andReturn([ + ['name' => 'users'], + ['name' => 'articles'], + ]); + $structure->shouldReceive('getBelongsToReference')->with('users')->andReturn(null); + $structure->shouldReceive('getBelongsToReference')->with('articles')->andReturn([ + 'user_id' => ['users', 'id'], + ]); + $structure->shouldReceive('getHasManyReference')->with('users')->andReturn([ + 'articles' => ['user_id'], + ]); + $structure->shouldReceive('getHasManyReference')->with('articles')->andReturn(null); + + $explorer = Mockery::mock(Explorer::class); + $explorer->shouldReceive('getStructure')->andReturn($structure); + + $toolkit = new DatabaseToolkit($explorer); + $result = $toolkit->getRelationships(); + + Assert::same(2, $result['count']); + + // belongsTo relationship + $belongsTo = array_filter($result['relationships'], fn($r) => $r['type'] === 'belongsTo'); + Assert::count(1, $belongsTo); + + // hasMany relationship + $hasMany = array_filter($result['relationships'], fn($r) => $r['type'] === 'hasMany'); + Assert::count(1, $hasMany); +}); + + +test('suggestEntity returns not implemented', function () { + $explorer = Mockery::mock(Explorer::class); + + $toolkit = new DatabaseToolkit($explorer); + $result = $toolkit->suggestEntity('users'); + + Assert::same('Not yet implemented', $result['error']); +}); diff --git a/tests/Toolkits/RouterToolkit.phpt b/tests/Toolkits/RouterToolkit.phpt new file mode 100644 index 0000000..ff6d0b7 --- /dev/null +++ b/tests/Toolkits/RouterToolkit.phpt @@ -0,0 +1,130 @@ +shouldReceive('getByType')->with(Router::class)->andThrow(new Exception('Not found')); + + $bridge = Mockery::mock(BootstrapBridge::class); + $bridge->shouldReceive('getContainer')->andReturn($container); + + $toolkit = RouterToolkit::tryCreate($bridge); + + Assert::null($toolkit); +}); + + +test('getRoutes returns list of routes', function () { + $router = new RouteList; + + $toolkit = new RouterToolkit($router); + $result = $toolkit->getRoutes(); + + Assert::same(0, $result['count']); + Assert::type('array', $result['routes']); +}); + + +test('matchUrl returns matched route info', function () { + $router = Mockery::mock(Router::class); + $router->shouldReceive('match')->andReturn([ + 'presenter' => 'Article', + 'action' => 'show', + 'id' => '123', + ]); + + $toolkit = new RouterToolkit($router); + $result = $toolkit->matchUrl('/article/123'); + + Assert::true($result['matched']); + Assert::same('Article', $result['presenter']); + Assert::same('show', $result['action']); + Assert::same(['id' => '123'], $result['params']); +}); + + +test('matchUrl returns no match', function () { + $router = Mockery::mock(Router::class); + $router->shouldReceive('match')->andReturn(null); + + $toolkit = new RouterToolkit($router); + $result = $toolkit->matchUrl('/nonexistent'); + + Assert::false($result['matched']); + Assert::same('No route matches this URL', $result['error']); +}); + + +test('matchUrl handles full URLs', function () { + $router = Mockery::mock(Router::class); + $router->shouldReceive('match')->andReturn([ + 'presenter' => 'Homepage', + 'action' => 'default', + ]); + + $toolkit = new RouterToolkit($router); + $result = $toolkit->matchUrl('https://example.com/'); + + Assert::true($result['matched']); + Assert::same('Homepage', $result['presenter']); +}); + + +test('generateUrl returns error when LinkGenerator not configured', function () { + $router = Mockery::mock(Router::class); + + $toolkit = new RouterToolkit($router); + $result = $toolkit->generateUrl('Article:show'); + + Assert::same('LinkGenerator not configured', $result['error']); +}); + + +test('generateUrl returns generated URL', function () { + $router = Mockery::mock(Router::class); + + // LinkGenerator is final, use anonymous class instead + $linkGenerator = new class { + public function link(string $dest, array $params = []): string + { + return '/article/123'; + } + }; + + $toolkit = new RouterToolkit($router, $linkGenerator); + $result = $toolkit->generateUrl('Article:show', ['id' => 123]); + + Assert::same('Article:show', $result['destination']); + Assert::same(['id' => 123], $result['params']); + Assert::same('/article/123', $result['url']); +}); + + +test('generateUrl handles errors', function () { + $router = Mockery::mock(Router::class); + + // LinkGenerator is final, use anonymous class instead + $linkGenerator = new class { + public function link(string $dest, array $params = []): string + { + throw new Exception('Cannot generate link'); + } + }; + + $toolkit = new RouterToolkit($router, $linkGenerator); + $result = $toolkit->generateUrl('Invalid:action'); + + Assert::same('Invalid:action', $result['destination']); + Assert::same('Cannot generate link', $result['error']); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c9eda30 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,31 @@ +fromGlobals(); + +$server = ServerFactory::create(); +$response = $server->runHttp($request, $psr17, $psr17); + +// Emit response +http_response_code($response->getStatusCode()); +foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header("$name: $value", false); + } +} +echo $response->getBody();