seatplus/eveapi

View on GitHub
src/Commands/CheckJobsCommand.php

Summary

Maintainability
A
2 hrs
Test Coverage
B
83%
<?php

/*
 * MIT License
 *
 * Copyright (c) 2019, 2020, 2021, 2022, 2023 Felix Huber
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace Seatplus\Eveapi\Commands;

use Exception;
use Illuminate\Console\Command;
use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ReflectionClass;
use Seatplus\Eveapi\Esi\HasCorporationRoleInterface;
use Seatplus\Eveapi\Esi\HasPathValuesInterface;
use Seatplus\Eveapi\Esi\HasRequiredScopeInterface;
use Seatplus\Eveapi\Jobs\Character\CharacterAffiliationJob;
use Seatplus\Eveapi\Jobs\Contracts\ContractItemsJob;
use Seatplus\Eveapi\Jobs\EsiBase;
use Seatplus\Eveapi\Jobs\Middleware\HasRequiredScopeMiddleware;
use Seatplus\Eveapi\Jobs\Wallet\WalletJournalBase;
use Seatplus\Eveapi\Jobs\Wallet\WalletTransactionBase;

use function Termwind\render;

class CheckJobsCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'seatplus:check:endpoints';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Check all used endpoints and whether the jobs are up to date or in need of an update';

    private array $esi_paths = [];

    private bool $has_errors = false;

    const URL = 'https://esi.evetech.net/latest/swagger.json';

    public function handle()
    {
        $this->getAllJobs()
            ->map(function ($job) {
                $assertions = $this->checkJob($job);

                $has_errors = $assertions->contains(fn ($assertion) => $assertion['status'] === 'error');
                $has_warnings = $assertions->contains(fn ($assertion) => $assertion['status'] === 'warning');

                return [
                    'class' => get_class($job),
                    'assertions' => $assertions,
                    'status' => $has_errors ? 'error' : ($has_warnings ? 'warning' : 'success'),
                ];
            })
            // sort by status, pass first, warning second, error last
            ->sortBy(fn ($job) => $job['status'] === 'error' ? 2 : ($job['status'] === 'warning' ? 1 : 0))
            ->each(function ($job) {
                // check if any assertion failed
                if ($job['status'] === 'error') {
                    //$this->writeAssertionOutput(get_class($job), 'px-2', '<span class="px-2 bg-red text-gray-400 uppercase">error</span>');
                    $this->writeAssertionHeader($job['class'], 'px-2 bg-red text-gray-400 uppercase', 'error');
                } elseif ($job['status'] === 'warning') {
                    $this->writeAssertionHeader($job['class'], 'px-2 bg-yellow text-gray-400 uppercase', 'warning');
                } else {
                    $this->writeAssertionHeader($job['class'], 'px-2 bg-green text-black uppercase', 'pass');
                }

                $job['assertions']->each(function ($assertion) {
                    match ($assertion['status']) {
                        'success' => $this->writeSuccess($assertion['message']),
                        'warning' => $this->writeWarning($assertion['message']),
                        'error' => $this->writeError($assertion['message']),
                        default => throw new Exception('Unknown status'),
                    };
                });

                $this->writeNewLine();
            });

        if ($this->has_errors) {
            return self::FAILURE;
        }

        return self::SUCCESS;
    }

    private function getAllJobs(): Collection
    {
        $jobs = glob(__DIR__.'/../Jobs/*/*.php');

        return collect($jobs)
            ->map(function ($job) {
                $job = str_replace(__DIR__.'/../Jobs/', '', $job);
                $job = str_replace('.php', '', $job);
                $job = str_replace('/', '\\', $job);
                $job = 'Seatplus\\Eveapi\\Jobs\\'.$job;

                return $job;
            })
            ->filter(fn ($job) => is_subclass_of($job, EsiBase::class))
            // filter out abstract classes
            ->filter(fn ($job) => ! (new ReflectionClass($job))->isAbstract())
            ->map(function ($job) {
                $constructor_parameters = (new ReflectionClass($job))->getConstructor()?->getParameters();
                $constructor_parameters = collect($constructor_parameters)
                    ->map(function (\ReflectionParameter $parameter) {
                        $type = 'unknown';

                        if ($parameter->getType() instanceof \ReflectionUnionType) {
                            // get the first type that is not array or null
                            $type = collect($parameter->getType()->getTypes())
                                ->filter(fn ($type) => ! in_array($type->getName(), ['array', 'null']))
                                ->first()?->getName();
                        }

                        if ($parameter->getType() instanceof \ReflectionNamedType) {
                            $type = $parameter->getType()->getName();
                        }

                        return match ($type) {
                            'int' => random_int(1, 1_000_000),
                            'string' => Str::random(),
                            default => throw new Exception('Unknown type'),
                        };
                    });

                return new $job(...$constructor_parameters->toArray());
            });
    }

    private function checkJob($job): Collection
    {
        return collect([])
            ->push($this->checkVersion($job))
            ->push($this->checkRequiredScope($job))
            ->push($this->checkPathValues($job))
            ->push($this->checkMiddleware($job))
            ->push($this->checkCorporationRoles($job))
            ->push($this->checkIsCheckingCache($job));
    }

    private function checkVersion(EsiBase $job): array
    {
        $version_string = $job->getVersion();

        // remove the v from string
        $version = (int) str_replace('v', '', $version_string);

        $alternative_versions = $this->getEsiPaths()[$job->getEndpoint()][$job->getMethod()]['x-alternate-versions'];

        if (! in_array($version_string, $alternative_versions)) {
            $available_versions = implode(', ', $alternative_versions);

            return [
                'status' => 'error',
                'message' => "version is outdated. Using $version_string but only $available_versions are available",
            ];
        }

        // check if version+1 is available
        $next_version = $version + 1;
        if (in_array('v'.$next_version, $alternative_versions)) {
            return [
                'status' => 'warning',
                'message' => "new version is available. Using $version_string but v$next_version is available",
            ];
        }

        return [
            'status' => 'success',
            'message' => 'version is up to date',
        ];
    }

    private function checkRequiredScope(EsiBase $job): array
    {
        // check if esi-path of job has security parameter
        $security = $this->getEsiPaths()[$job->getEndpoint()][$job->getMethod()]['security'] ?? null;

        if (is_null($security)) {
            if ($job instanceof HasRequiredScopeInterface) {
                return $this->assertionResult('error', 'job requires authentication but endpoint does not have security parameter');
            }

            return $this->assertionResult('success', 'no security scope required');
        }

        // now we know the endpoint requires authentication we must check if the job sets the required scope correctly

        // check if job implements HasRequiredScopeInterface
        if (! $job instanceof HasRequiredScopeInterface) {
            return $this->assertionResult('error', 'job requires authentication but does not implement HasRequiredScopeInterface');
        }

        $job_required_scope = $job->getRequiredScope();
        $endpoint_required_scope = $security[0]['evesso'][0];

        if ($job_required_scope !== $endpoint_required_scope) {
            return $this->assertionResult('error', "job requires scope $job_required_scope but endpoint requires $endpoint_required_scope");
        }

        return $this->assertionResult('success', 'security scope required');
    }

    private function checkPathValues(EsiBase $job): array
    {
        if (! $job instanceof HasPathValuesInterface) {
            // Check if any mustache syntax is used in path
            if (str_contains($job->getEndpoint(), '{')) {
                return $this->assertionResult('error', 'path values are required but job does not implement HasPathValuesInterface');
            }

            return $this->assertionResult('success', 'no path values required');
        }

        $path_values = $job->getPathValues();

        if (empty($path_values)) {
            return $this->assertionResult('error', 'no path values set even though job requires path values');
        }

        $path = $job->getEndpoint();

        foreach ($path_values as $key => $value) {
            if (! str_contains($path, $key)) {
                return $this->assertionResult('error', "path value $key is not used in path");
            }
        }

        return $this->assertionResult('success', 'path values all set');
    }

    private function checkMiddleware(EsiBase $job): array
    {
        $used_middlewares = collect($job->middleware());

        // first we check if ThrottlesExceptionsWithRedis Middleware is used
        if (! $used_middlewares->first(fn ($middleware) => get_class($middleware) === ThrottlesExceptionsWithRedis::class)) {
            return $this->assertionResult('error', 'ThrottlesExceptionsWithRedis Middleware is not used');
        }

        // now check all jobs that require authentication implementing HasRequiredScopeMiddleware
        if ($job instanceof HasRequiredScopeInterface) {
            // check if the required scope middleware is used
            if (! $used_middlewares->first(fn ($middleware) => get_class($middleware) === HasRequiredScopeMiddleware::class)) {
                return $this->assertionResult('error', 'HasRequiredScopeMiddleware is not used even though job requires authentication');
            }
        }

        return $this->assertionResult('success', 'All required middlewares are used');
    }

    private function checkCorporationRoles(EsiBase $job): array
    {
        $required_roles = $this->getEsiPaths()[$job->getEndpoint()][$job->getMethod()]['x-required-roles'] ?? [];

        // if no roles are required, return success
        if (empty($required_roles)) {
            return $this->assertionResult('success', 'no corporate roles required');
        }

        // check if job implements HasCorporationRolesInterface
        if (! $job instanceof HasCorporationRoleInterface) {
            return $this->assertionResult('error', 'job requires corporation roles but does not implement HasCorporationRoleInterface');
        }

        $job_required_roles = $job->getCorporationRoles();

        // check if job has required roles set
        if (empty($job_required_roles)) {
            return $this->assertionResult('error', 'job requires corporation roles but does not set them');
        }

        // check if job has all required roles set
        foreach ($required_roles as $required_role) {
            if (! in_array($required_role, $job_required_roles)) {
                $required_roles_string = implode(', ', $required_roles);

                return $this->assertionResult('error', "job requires corporate roles ($required_roles_string) but $required_role is not set");
            }
        }

        return $this->assertionResult('success', 'all required corporate roles are set');
    }

    private function checkIsCheckingCache(EsiBase $job): array
    {
        $cached_seconds = $this->getEsiPaths()[$job->getEndpoint()][$job->getMethod()]['x-cached-seconds'] ?? null;
        $has_cached_seconds = ! is_null($cached_seconds);

        // if method is post and has cached seconds, return warning
        if ($job->getMethod() === 'post' && $has_cached_seconds) {
            // if job is CharacterAffiliationJob, return success
            if (get_class($job) === CharacterAffiliationJob::class) {
                return $this->assertionResult('success', 'CharacterAffiliationJob is a post request but has cached seconds');
            }

            return $this->assertionResult('warning', 'job is a post request but has cached seconds');
        }

        // get filename of job class
        $job_class = get_class($job);
        $reflection_class = new ReflectionClass($job_class);
        $job_filename = $reflection_class->getFileName();

        // check if job is extending a BaseJob
        foreach ([ContractItemsJob::class, WalletJournalBase::class, WalletTransactionBase::class] as $base_job) {
            // check if base_job is abstract
            $reflection_class = new ReflectionClass($base_job);

            throw_unless($reflection_class->isAbstract(), new Exception("${base_job} is not abstract but checker is overwriting job_filename"));

            if (is_subclass_of($job_class, $base_job)) {
                $job_filename = $reflection_class->getFileName();
            }
        }

        // check if job isCachedLoad() is called somewhere in the job
        $job_source = file_get_contents($job_filename);

        $has_is_cached_load = str_contains($job_source, 'isCachedLoad()');

        // if the endpoint is not cached but the job checks if the response is cached, return error
        if (! $has_cached_seconds && $has_is_cached_load) {
            return $this->assertionResult('error', 'job checks if response is cached but endpoint is not cached');
        }

        // if the endpoint is cached but the job does not check if the response is cached, return error
        if ($has_cached_seconds && ! $has_is_cached_load) {
            return $this->assertionResult('error', 'job does not check if response is cached but endpoint is cached');
        }

        return $this->assertionResult('success', 'job checks if response is cached');
    }

    private function assertionResult(string $status, string $message): array
    {
        return [
            'status' => $status,
            'message' => $message,
        ];
    }

    private function writeSuccess(string $message): void
    {
        $this->writeAssertionOutput($message, 'text-green font-bold px-2', '✓');
    }

    private function writeError(string $message): void
    {
        $this->writeAssertionOutput($message, 'text-red font-bold px-2', '⨯');
        $this->has_errors = true;
    }

    private function writeWarning(string $message): void
    {
        $this->writeAssertionOutput($message, 'text-yellow font-bold px-2', '-');
    }

    private function writeAssertionOutput(string $message, string $symbol_class, string $symbol): void
    {
        $output = sprintf('<div class="text-gray-800"><span class="%s">%s</span>%s</div>', $symbol_class, $symbol, $message);

        render($output);
    }

    private function writeAssertionHeader(string $job, string $status_class, string $status): void
    {
        $output = sprintf('<div><div class="px-2"><span class="%s">%s</span></div>%s</div>', $status_class, $status, $job);

        render($output);
    }

    private function writeNewLine(): void
    {
        render(PHP_EOL);
    }

    public function getEsiPaths(): array
    {
        if (empty($this->esi_paths)) {

            $this->esi_paths = Http::acceptJson()
                ->get(self::URL)
                ->json('paths');
        }

        return $this->esi_paths;
    }
}