diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..c89bb6a --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,99 @@ +name: Static Analysis + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + +jobs: + phpstan: + name: PHPStan Analysis + runs-on: ubuntu-latest + + strategy: + matrix: + include: + # Laravel 10 with Larastan 2.x + - php-version: '8.2' + laravel-version: '10.*' + larastan-version: '^2.0' + - php-version: '8.3' + laravel-version: '10.*' + larastan-version: '^2.0' + + # Laravel 11 with Larastan 2.x + - php-version: '8.2' + laravel-version: '11.*' + larastan-version: '^2.0' + - php-version: '8.3' + laravel-version: '11.*' + larastan-version: '^2.0' + + # Laravel 12 with Larastan 3.x (when available) + - php-version: '8.2' + laravel-version: '12.*' + larastan-version: '^3.1' + - php-version: '8.3' + laravel-version: '12.*' + larastan-version: '^3.1' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-laravel-${{ matrix.laravel-version }}-larastan-${{ matrix.larastan-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}-laravel-${{ matrix.laravel-version }}- + + - name: Install dependencies + run: | + composer require "illuminate/support:${{ matrix.laravel-version }}" "larastan/larastan:${{ matrix.larastan-version }}" --no-interaction --no-update + composer install --prefer-dist --no-interaction + + - name: Run PHPStan + run: composer run phpstan + + code-quality: + name: Code Quality Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Install dependencies (with Laravel 11 and Larastan 2.x for compatibility) + run: | + composer require "illuminate/support:^11.0" "larastan/larastan:^2.0" --no-interaction --no-update + composer install --prefer-dist --no-interaction + + - name: Check code style (if PHP CS Fixer is available) + run: | + if [ -f vendor/bin/php-cs-fixer ]; then + vendor/bin/php-cs-fixer fix --dry-run --diff + else + echo "PHP CS Fixer not installed, skipping code style check" + fi + continue-on-error: true + + - name: Run static analysis + run: composer run phpstan \ No newline at end of file diff --git a/.gitignore b/.gitignore index fde868e..260aa36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,19 @@ composer.phar composer.lock /vendor/ + +# PHPStan +phpstan-baseline.neon +.phpunit.result.cache + +# Development files (keep phpstan-bootstrap.php in repo for static analysis) + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 032a94f..9072fd0 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,18 @@ Invalid dashboard or question ID. ### Message seems corrupt or manipulated. Invalid secret key. + +## Development + +This package uses [Larastan](https://github.com/larastan/larastan) for static analysis at level 9 to ensure code quality. + +### Running Static Analysis +```bash +composer phpstan +``` + +### Requirements +- PHP 8.2+ +- Laravel 8.0+ (supports up to Laravel 12.x) + +For detailed static analysis information, see [STATIC_ANALYSIS.md](STATIC_ANALYSIS.md). diff --git a/STATIC_ANALYSIS.md b/STATIC_ANALYSIS.md new file mode 100644 index 0000000..0f13ab5 --- /dev/null +++ b/STATIC_ANALYSIS.md @@ -0,0 +1,144 @@ +# Static Analysis with Larastan + +This package uses [Larastan](https://github.com/larastan/larastan) (PHPStan for Laravel) for static analysis to ensure code quality and catch potential bugs before runtime. + +## Configuration + +The static analysis is configured at **level 9** (the highest level) in `phpstan.neon` for maximum code quality assurance. + +### Version Compatibility + +Due to Larastan's version constraints, different Laravel versions require different Larastan versions: + +- **Laravel 8-11**: Uses Larastan `^2.0` +- **Laravel 12**: Uses Larastan `^3.1` (when available) + +The package automatically handles these constraints in `composer.json`. + +### Key Features + +- **Level 9 Analysis**: The strictest level of static analysis +- **Laravel Integration**: Full Laravel framework support through Larastan +- **Type Safety**: Comprehensive type checking and validation +- **Generic Types**: Support for generic collections and arrays +- **Uninitialized Properties**: Detection of uninitialized class properties +- **Return Type Analysis**: Verification of method return types + +## Running Static Analysis + +### Local Development + +```bash +# Run static analysis +composer phpstan + +# Alternative commands +composer analyse +composer test-static +composer check + +# Clear PHPStan cache +composer phpstan-clear + +# Generate baseline (if needed) +composer phpstan-baseline +``` + +### Memory Usage + +The analysis runs with 1GB memory limit by default. If you encounter memory issues: + +```bash +# Increase memory limit +vendor/bin/phpstan analyse --memory-limit=2G +``` + +## Continuous Integration + +Static analysis runs automatically on: +- Push to main/master/develop branches +- Pull requests to main/master/develop branches +- Multiple PHP versions (8.2, 8.3) +- Multiple Laravel versions with compatible Larastan versions: + - Laravel 10.x + Larastan 2.x + - Laravel 11.x + Larastan 2.x + - Laravel 12.x + Larastan 3.x + +## Configuration Details + +### Analysis Paths +- `src/` - Main source code +- `config/` - Configuration files +- `routes/` - Route definitions + +### Excluded Paths +- `vendor/` - Third-party packages +- `storage/` - Laravel storage directory +- `bootstrap/cache/` - Laravel cache files + +### Bootstrap Files +- `phpstan-bootstrap.php` - Custom Laravel helper function definitions for static analysis + +### Configuration Features +- **Level 9 Analysis**: Maximum strictness for type checking +- **Laravel Integration**: Full framework support via Larastan extension +- **Custom Bootstrap**: Laravel helper function definitions +- **Strategic Ignores**: Allows Laravel-specific patterns while maintaining strictness + +### Laravel-Specific Handling +- Ignores Laravel helper functions (`config()`, `view()`, `app()`) +- Handles Laravel facades and service container +- Supports Blade component analysis + +## Benefits + +1. **Early Bug Detection**: Catch type errors before runtime +2. **Better IDE Support**: Enhanced autocompletion and refactoring +3. **Documentation**: Type hints serve as inline documentation +4. **Refactoring Safety**: Confident code changes with type checking +5. **Team Consistency**: Enforced coding standards across the team + +## Troubleshooting + +### Common Issues + +1. **Memory Errors**: Increase memory limit in composer scripts +2. **False Positives**: Add specific ignores to `phpstan.neon` +3. **Missing Types**: Add proper PHPDoc annotations +4. **Configuration Errors**: Ensure only valid PHPStan parameters are used +5. **Version Conflicts**: Ensure Laravel and Larastan versions are compatible: + ```bash + # For Laravel 10-11 + composer require --dev "larastan/larastan:^2.0" + + # For Laravel 12 + composer require --dev "larastan/larastan:^3.1" + ``` + +### Baseline Usage + +If you need to temporarily ignore existing issues: + +```bash +composer phpstan-baseline +``` + +This creates a `phpstan-baseline.neon` file with current issues that will be ignored in future runs. + +## Best Practices + +1. **Run Before Commits**: Always run static analysis before committing +2. **Fix Issues Promptly**: Don't let static analysis issues accumulate +3. **Use Strict Types**: Leverage PHP 8.2+ features for better type safety +4. **Document Complex Types**: Use PHPDoc for complex generic types +5. **Review Ignores**: Regularly review and remove unnecessary ignores + +## Integration with IDEs + +Most modern IDEs support PHPStan integration: + +- **PhpStorm**: Built-in support with PHPStan plugin +- **VS Code**: Use the PHPStan extension +- **Vim/Neovim**: Various PHPStan plugins available + +This ensures real-time feedback while coding, not just during CI/CD. \ No newline at end of file diff --git a/check-larastan-compatibility.sh b/check-larastan-compatibility.sh new file mode 100755 index 0000000..fc985da --- /dev/null +++ b/check-larastan-compatibility.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Script to check and install compatible Larastan version +# Usage: ./check-larastan-compatibility.sh + +echo "🔍 Checking Laravel and Larastan compatibility..." + +# Check if composer.json exists +if [ ! -f "composer.json" ]; then + echo "❌ composer.json not found in current directory" + exit 1 +fi + +# Extract Laravel version from composer.json +LARAVEL_VERSION=$(php -r " +\$composer = json_decode(file_get_contents('composer.json'), true); +\$illuminate = \$composer['require']['illuminate/support'] ?? ''; +echo \$illuminate; +") + +if [ -z "$LARAVEL_VERSION" ]; then + echo "❌ Could not determine Laravel version from composer.json" + exit 1 +fi + +echo "📦 Found illuminate/support constraint: $LARAVEL_VERSION" + +# Determine appropriate Larastan version +if [[ $LARAVEL_VERSION == *"12."* ]]; then + LARASTAN_VERSION="^3.1" + echo "✅ Laravel 12 detected - recommending Larastan $LARASTAN_VERSION" +elif [[ $LARAVEL_VERSION == *"11."* ]] || [[ $LARAVEL_VERSION == *"10."* ]] || [[ $LARAVEL_VERSION == *"9."* ]] || [[ $LARAVEL_VERSION == *"8."* ]]; then + LARASTAN_VERSION="^2.0" + echo "✅ Laravel 8-11 detected - recommending Larastan $LARASTAN_VERSION" +else + echo "⚠️ Could not determine appropriate Larastan version for Laravel constraint: $LARAVEL_VERSION" + echo " Please check manually at: https://github.com/larastan/larastan#compatibility" + exit 1 +fi + +echo "" +echo "🚀 To install the compatible version, run:" +echo " composer require --dev \"larastan/larastan:$LARASTAN_VERSION\"" +echo "" +echo "📝 Then run static analysis with:" +echo " composer phpstan" diff --git a/composer.json b/composer.json index 7b5d168..4fafa1d 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ } ], "require": { - "php": "^8.0", - "illuminate/support": "^8.0|^9.0", - "lcobucci/jwt": "^4.1" + "php": "^8.2", + "illuminate/support": "12.*", + "lcobucci/jwt": "^4.1|^5.0" }, "autoload": { "psr-4": { @@ -31,6 +31,19 @@ ] } }, + "scripts": { + "phpstan": "phpstan analyse", + "phpstan-baseline": "phpstan analyse --generate-baseline --memory-limit=1G", + "phpstan-clear": "phpstan clear-result-cache", + "test-static": "@phpstan", + "analyse": "@phpstan", + "check": [ + "@phpstan" + ] + }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "require-dev": { + "larastan/larastan": "^3.7" + } } diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..f8e53ca --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,108 @@ +make($abstract, $parameters); + } +} + +if (!function_exists('config')) { + /** + * Get / set the specified configuration value. + * + * @param array|string|null $key + * @param mixed $default + * @return mixed|\Illuminate\Config\Repository + */ + function config($key = null, $default = null) + { + if (is_null($key)) { + return new \Illuminate\Config\Repository(); + } + + if (is_array($key)) { + return new \Illuminate\Config\Repository(); + } + + return $default; + } +} + +if (!function_exists('view')) { + /** + * Get the evaluated view contents for the given view. + * + * @param string|null $view + * @param array $data + * @param array $mergeData + * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory + */ + function view($view = null, $data = [], $mergeData = []) + { + $factory = new class implements \Illuminate\Contracts\View\Factory { + public function exists($view) { return true; } + public function file($path, $data = [], $mergeData = []) { return $this->make('', $data, $mergeData); } + public function make($view, $data = [], $mergeData = []) { + return new class implements \Illuminate\Contracts\View\View { + public function name() { return ''; } + public function with($key, $value = null) { return $this; } + public function getData() { return []; } + public function render() { return ''; } + }; + } + public function composer($views, $callback) { return []; } + public function creator($views, $callback) { return []; } + public function addNamespace($namespace, $hints) { return $this; } + public function replaceNamespace($namespace, $hints) { return $this; } + }; + + if (is_null($view)) { + return $factory; + } + + return $factory->make($view, $data, $mergeData); + } +} + +if (!function_exists('resource_path')) { + /** + * Get the path to the resources folder. + * + * @param string $path + * @return string + */ + function resource_path($path = '') + { + return '/resources' . ($path ? DIRECTORY_SEPARATOR . $path : $path); + } +} + +if (!function_exists('config_path')) { + /** + * Get the configuration path. + * + * @param string $path + * @return string + */ + function config_path($path = '') + { + return '/config' . ($path ? DIRECTORY_SEPARATOR . $path : $path); + } +} \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..697aea0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,34 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 9 + paths: + - src + - config + - routes + + excludePaths: + - vendor + - storage + - bootstrap/cache + + bootstrapFiles: + - phpstan-bootstrap.php + + ignoreErrors: + # Allow mixed returns from helpers when we can't determine exact type + - '#Method .* should return .* but returns mixed#' + + # Ignore issues with route resource method chaining + - '#Call to method only\(\) on an unknown class Illuminate\\Routing\\PendingResourceRegistration#' + + # Allow Laravel helper functions that return mixed + - '#Function config not found#' + - '#Function view not found#' + - '#Function app not found#' + - '#Function resource_path not found#' + - '#Function config_path not found#' + + # Allow Laravel base classes + - '#Class .* extends unknown class Illuminate\\.*#' diff --git a/routes/web.php b/routes/web.php index 173bc7e..57af93c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,20 @@ config('laravolt.metabase.route.prefix'), + 'prefix' => $prefix, 'as' => 'metabase::', - 'middleware' => config('laravolt.metabase.route.middleware'), + 'middleware' => $middleware, ], function () { - Route::resource('embed', \Laravolt\Metabase\Controllers\EmbedController::class)->only('show'); + Route::resource('embed', EmbedController::class)->only('show'); } ); diff --git a/src/Controllers/EmbedController.php b/src/Controllers/EmbedController.php index 12e27f6..92789f4 100644 --- a/src/Controllers/EmbedController.php +++ b/src/Controllers/EmbedController.php @@ -9,10 +9,12 @@ class EmbedController extends Controller /** * @param int $id * @param array $params - * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View + * @return \Illuminate\Contracts\View\View */ - public function show(int $id, $params = []) + public function show(int $id, array $params = []): \Illuminate\Contracts\View\View { - return view('metabase::embed.show', compact('id', 'params')); + /** @var \Illuminate\Contracts\View\View $view */ + $view = view('metabase::embed.show', compact('id', 'params')); + return $view; } } diff --git a/src/MetabaseComponent.php b/src/MetabaseComponent.php index b9427ea..3ca4575 100644 --- a/src/MetabaseComponent.php +++ b/src/MetabaseComponent.php @@ -17,7 +17,7 @@ class MetabaseComponent extends Component public ?string $theme; /** - * @var string[] + * @var array|null */ public ?array $params; @@ -26,9 +26,12 @@ class MetabaseComponent extends Component * * @param int|null $dashboard * @param int|null $question - * @param array $params + * @param array $params + * @param bool $bordered + * @param bool $titled + * @param string|null $theme */ - public function __construct(?int $dashboard = null, ?int $question = null, array $params = [], $bordered = false, $titled = false, $theme = null) + public function __construct(?int $dashboard = null, ?int $question = null, array $params = [], bool $bordered = false, bool $titled = false, ?string $theme = null) { $this->dashboard = $dashboard; $this->question = $question; @@ -41,24 +44,35 @@ public function __construct(?int $dashboard = null, ?int $question = null, array /** * Get the view / contents that represent the component. * - * @return \Illuminate\Contracts\View\View|string + * @return \Illuminate\Contracts\View\View */ - public function render() + public function render(): \Illuminate\Contracts\View\View { + /** @var MetabaseService $metabase */ $metabase = app(MetabaseService::class); - $metabase->setParams($this->params); + $metabase->setParams($this->params ?? []); $metabase->setAdditionalParams($this->getAdditionalParams()); - $iframeUrl = $metabase->generateEmbedUrl((int) $this->dashboard, (int) $this->question); - return view('metabase::iframe', compact('iframeUrl')); + $iframeUrl = $metabase->generateEmbedUrl($this->dashboard, $this->question); + + /** @var \Illuminate\Contracts\View\View $view */ + $view = view('metabase::iframe', compact('iframeUrl')); + return $view; } - private function getAdditionalParams() + /** + * Get additional parameters for the iframe URL. + * + * @return array + */ + private function getAdditionalParams(): array { - $additionalParameters['bordered'] = $this->bordered; - $additionalParameters['titled'] = $this->titled; + $additionalParameters = [ + 'bordered' => $this->bordered, + 'titled' => $this->titled, + ]; - if($this->theme) { + if ($this->theme) { $additionalParameters['theme'] = $this->theme; } diff --git a/src/MetabaseService.php b/src/MetabaseService.php index 797443f..2309602 100644 --- a/src/MetabaseService.php +++ b/src/MetabaseService.php @@ -10,19 +10,19 @@ class MetabaseService { /** - * @var array @params + * @var array */ - private $params; + private array $params = []; /** - * @var array @params + * @var array */ - private $additionalParams; + private array $additionalParams = []; + + private string $type = 'dashboard'; /** - * @param array $params - * - * @return void + * @param array $params */ public function setParams(array $params): void { @@ -30,57 +30,68 @@ public function setParams(array $params): void } /** - * @param array $params - * - * @return void + * @param array $params */ public function setAdditionalParams(array $params): void { $this->additionalParams = $params; } - private string $type = 'dashboard'; - /** + * Generate the embed URL for a Metabase dashboard or question. + * * @param int|null $dashboard * @param int|null $question - * * @return string + * @throws InvalidArgumentException */ public function generateEmbedUrl(?int $dashboard, ?int $question): string { + /** @var mixed $secret */ + $secret = config('services.metabase.secret'); + /** @var mixed $baseUrl */ + $baseUrl = config('services.metabase.url'); + + if (empty($secret) || !is_string($secret)) { + throw new InvalidArgumentException('Metabase secret is not configured or invalid'); + } + + if (empty($baseUrl) || !is_string($baseUrl)) { + throw new InvalidArgumentException('Metabase URL is not configured or invalid'); + } + $config = Configuration::forSymmetricSigner( new Sha256(), - InMemory::plainText(config('services.metabase.secret')) + InMemory::plainText($secret) ); - $builder = $config - ->builder(); + $builder = $config->builder(); - if ($dashboard) { + if ($dashboard !== null) { $builder->withClaim('resource', ['dashboard' => $dashboard]); - } elseif ($question) { + $this->type = 'dashboard'; + } elseif ($question !== null) { $builder->withClaim('resource', ['question' => $question]); $this->type = 'question'; } else { - throw new InvalidArgumentException('Dashboard or question must be specified'); + throw new InvalidArgumentException('Either dashboard or question must be specified'); } - $params = $this->params; - if (empty($params)) { - $params = (object) $params; - } + $params = empty($this->params) ? (object) [] : $this->params; $builder->withClaim('params', $params); $token = $builder ->getToken($config->signer(), $config->signingKey()) ->toString(); + $additionalQuery = !empty($this->additionalParams) ? '#' . http_build_query($this->additionalParams) : ''; + return sprintf( - '%s/embed/%s/%s#'.http_build_query($this->additionalParams), - config('services.metabase.url'), + '%s/embed/%s/%s%s', + rtrim($baseUrl, '/'), $this->type, - $token + $token, + $additionalQuery ); } } diff --git a/src/MetabaseServiceProvider.php b/src/MetabaseServiceProvider.php index 5eddd0e..2d99d4c 100644 --- a/src/MetabaseServiceProvider.php +++ b/src/MetabaseServiceProvider.php @@ -7,15 +7,35 @@ class MetabaseServiceProvider extends ServiceProvider { - /** - * @return void - */ public function boot(): void { $viewPath = __DIR__.'/../resources/views'; + $configPath = __DIR__.'/../config/metabase.php'; + $this->loadViewsFrom($viewPath, 'metabase'); - $this->publishes([$viewPath => resource_path('views/vendor/metabase')]); + + // Publish views + $this->publishes([ + $viewPath => resource_path('views/vendor/metabase') + ], 'metabase-views'); + + // Publish config + $this->publishes([ + $configPath => config_path('metabase.php') + ], 'metabase-config'); + + // Load routes if enabled + /** @var mixed $routeEnabled */ + $routeEnabled = config('metabase.route.enabled', true); + if ($routeEnabled) { + $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); + } Blade::component('metabase', MetabaseComponent::class); } + + public function register(): void + { + $this->mergeConfigFrom(__DIR__.'/../config/metabase.php', 'metabase'); + } }