diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c07380f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + schedule: + - cron: '0 0 * * *' + +jobs: + php81: + name: PHP 8.1 + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: composer test + uses: docker://chubbyphp/ci-php81:latest + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + php82: + name: PHP 8.2 + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: composer test + uses: docker://chubbyphp/ci-php82:latest + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + php83: + name: PHP 8.3 + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: composer test + uses: docker://chubbyphp/ci-php83:latest + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + - name: sonarcloud.io + uses: sonarsource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91ca7d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.idea/ +.phpunit.cache +.vscode/ +build/ +composer.lock +vendor/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..1a57ecd --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,24 @@ +files() + ->name('*.php') + ->in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') +; + +/** @var array $config */ +$config = require __DIR__ . '/vendor/chubbyphp/chubbyphp-dev-helper/phpcs.php'; + +// rules is buggy +unset($config['rules']['simplified_null_return']); + +return (new PhpCsFixer\Config) + ->setIndent($config['indent']) + ->setLineEnding($config['lineEnding']) + ->setRules($config['rules']) + ->setRiskyAllowed($config['riskyAllowed']) + ->setFinder($finder) +; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f71a527 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Dominik Zogg + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f86f16 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# chubbyphp-parsing + +[![CI](https://github.com/chubbyphp/chubbyphp-parsing/workflows/CI/badge.svg?branch=master)](https://github.com/chubbyphp/chubbyphp-parsing/actions?query=workflow%3ACI) +[![Coverage Status](https://coveralls.io/repos/github/chubbyphp/chubbyphp-parsing/badge.svg?branch=master)](https://coveralls.io/github/chubbyphp/chubbyphp-parsing?branch=master) +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fchubbyphp%2Fchubbyphp-parsing%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/chubbyphp/chubbyphp-parsing/master) +[![Latest Stable Version](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/v/stable.png)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) +[![Total Downloads](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/downloads.png)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) +[![Monthly Downloads](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/d/monthly)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) + +[![bugs](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=bugs)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![code_smells](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=code_smells)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![coverage](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=coverage)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![duplicated_lines_density](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![ncloc](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=ncloc)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![sqale_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![alert_status](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=alert_status)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![reliability_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![security_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=security_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![sqale_index](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=sqale_index)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) +[![vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) + + +## Description + + + +## Requirements + + * php: ^8.1 + +## Installation + +Through [Composer](http://getcomposer.org) as [chubbyphp/chubbyphp-parsing][1]. + +```sh +composer require chubbyphp/chubbyphp-parsing "^1.0" +``` + +## Usage + +## Copyright + +2024 Dominik Zogg + +[1]: https://packagist.org/packages/chubbyphp/chubbyphp-parsing diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..303d79c --- /dev/null +++ b/composer.json @@ -0,0 +1,65 @@ +{ + "name": "chubbyphp/chubbyphp-parsing", + "description": "Chubbyphp Parse", + "keywords": [ + "chubbyphp" + ], + "license": "MIT", + "authors": [ + { + "name": "Dominik Zogg", + "email": "dominik.zogg@gmail.com" + } + ], + "require": { + "php": "^8.1" + }, + "require-dev": { + "chubbyphp/chubbyphp-dev-helper": "dev-master", + "chubbyphp/chubbyphp-mock": "^1.7", + "infection/infection": "^0.27.8", + "php-coveralls/php-coveralls": "^2.7.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.45", + "phpunit/phpunit": "^10.4.2" + }, + "autoload": { + "psr-4": { + "Chubbyphp\\Parsing\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Chubbyphp\\Tests\\Parsing\\": "tests/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "scripts": { + "fix:cs": "mkdir -p build && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --cache-file=build/phpcs.cache", + "test": [ + "@test:lint", + "@test:unit", + "@test:integration", + "@test:infection", + "@test:static-analysis", + "@test:cs" + ], + "test:cs": "mkdir -p build && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --cache-file=build/phpcs.cache", + "test:infection": "vendor/bin/infection --threads=$(nproc) --min-msi=97 --verbose --coverage=build/phpunit", + "test:integration": "vendor/bin/phpunit --testsuite=Integration --cache-result-file=build/phpunit/result.cache", + "test:lint": "mkdir -p build && find src tests -name '*.php' -print0 | xargs -0 -n1 -P$(nproc) php -l | tee build/phplint.log", + "test:static-analysis": "mkdir -p build && bash -c 'vendor/bin/phpstan analyse src --no-progress --level=8 --error-format=junit | tee build/phpstan.junit.xml; if [ ${PIPESTATUS[0]} -ne \"0\" ]; then exit 1; fi'", + "test:unit": "vendor/bin/phpunit --testsuite=Unit --coverage-text --coverage-clover=build/phpunit/clover.xml --coverage-html=build/phpunit/coverage-html --coverage-xml=build/phpunit/coverage-xml --log-junit=build/phpunit/junit.xml --cache-result-file=build/phpunit/result.cache" + } +} diff --git a/infection.json b/infection.json new file mode 100644 index 0000000..96e37a6 --- /dev/null +++ b/infection.json @@ -0,0 +1,20 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "timeout": 10, + "logs": { + "text": "build/phpinfection/infection.log", + "html": "build/phpinfection/infection.html", + "json": "build/phpinfection/infection.json", + "summary": "build/phpinfection/summary.log", + "stryker": { + "report": "master" + } + }, + "mutators": { + "@default": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f51e71c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..8d051b4 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + + ./tests/Integration + + + ./tests/Unit + + + + + ./src + + + diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..6dadf1c --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,10 @@ +sonar.organization=chubbyphp +sonar.projectKey=chubbyphp_chubbyphp-parsing +sonar.projectName=chubbyphp-parsing + +sonar.sources=src +sonar.tests=tests +sonar.language=php +sonar.sourceEncoding=UTF-8 +sonar.php.coverage.reportPaths=build/phpunit/clover.xml +sonar.php.tests.reportPath=build/phpunit/junit.xml diff --git a/src/ParseError.php b/src/ParseError.php new file mode 100644 index 0000000..da9f76b --- /dev/null +++ b/src/ParseError.php @@ -0,0 +1,20 @@ +getData(); + } + + public function getData(): array|string + { + return $this->data; + } +} diff --git a/src/ParseErrorInterface.php b/src/ParseErrorInterface.php new file mode 100644 index 0000000..d0ec6c4 --- /dev/null +++ b/src/ParseErrorInterface.php @@ -0,0 +1,12 @@ + + */ + private array $parseErrors; + + /** + * @param array $parseErrors + */ + public function __construct(array $parseErrors) + { + foreach ($parseErrors as $key => $parseError) { + if (!$parseError instanceof ParseErrorInterface) { + $type = \is_object($parseError) ? $parseError::class : \gettype($parseError); + + throw new \InvalidArgumentException(sprintf('Argument #1 value of #%s ($parseErrors) must be of type ParseError, %s given', (string) $key, $type)); + } + } + + $this->parseErrors = $parseErrors; + } + + public function __toString(): string + { + return json_encode($this->getData()); + } + + public function getData(): array|string + { + $data = []; + foreach ($this->parseErrors as $key => $parseError) { + $data[$key] = $parseError->getData(); + } + + return $data; + } +} diff --git a/src/Parser.php b/src/Parser.php new file mode 100644 index 0000000..c763da5 --- /dev/null +++ b/src/Parser.php @@ -0,0 +1,32 @@ + $objectSchema + * @param class-string $classname + */ + public function object(array $objectSchema, string $classname = \stdClass::class): ObjectSchema + { + return new ObjectSchema($objectSchema, $classname); + } + + public function string(): StringSchema + { + return new StringSchema(); + } +} diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..102ae67 --- /dev/null +++ b/src/Result.php @@ -0,0 +1,15 @@ +success = null === $error; + } +} diff --git a/src/Schema/ArraySchema.php b/src/Schema/ArraySchema.php new file mode 100644 index 0000000..e33dd4a --- /dev/null +++ b/src/Schema/ArraySchema.php @@ -0,0 +1,124 @@ + &$parseError): mixed> + */ + private array $transform = []; + + private mixed $default = null; + + /** + * @var \Closure(mixed, ParseErrorInterface): mixed + */ + private mixed $catch = null; + + private bool $nullable = false; + + public function __construct(private SchemaInterface $itemSchema) {} + + public function parse(mixed $input): null|array + { + $input ??= $this->default; + + if (null === $input && $this->nullable) { + return null; + } + + try { + if (!\is_array($input)) { + throw new ParseError(sprintf("Input needs to be array, '%s'", \gettype($input))); + } + + $output = []; + $parseErrors = []; + + foreach ($input as $i => $item) { + try { + $output[$i] = $this->itemSchema->parse($item); + } catch (ParseErrorInterface $parseError) { + $parseErrors[$i] = $parseError; + } + } + + foreach ($this->transform as $transform) { + $output = $transform($output, $parseErrors); + } + + if (\count($parseErrors)) { + throw new ParseErrors($parseErrors); + } + + return $output; + } catch (ParseErrorInterface $parseError) { + if ($this->catch) { + return ($this->catch)($input, $parseError); + } + + throw $parseError; + } + } + + public function safeParse(mixed $input): Result + { + try { + return new Result($this->parse($input), null); + } catch (ParseErrorInterface $parseError) { + return new Result(null, $parseError); + } + } + + /** + * @param \Closure(mixed $input, array &$parseError): mixed $transform + */ + public function transform(\Closure $transform): static + { + $this->transform[] = $transform; + + return $this; + } + + public function default(mixed $default): static + { + $this->default = $default; + + return $this; + } + + /** + * @param \Closure(mixed $input, ParseErrorInterface $parseError): mixed $catch + */ + public function catch(\Closure $catch): static + { + $this->catch = $catch; + + return $this; + } + + public function nullable(): static + { + $this->nullable = true; + + return $this; + } + + /** + * @return array + */ + public function array(): array + { + return [ + clone $this, + ]; + } +} diff --git a/src/Schema/ObjectSchema.php b/src/Schema/ObjectSchema.php new file mode 100644 index 0000000..b89502e --- /dev/null +++ b/src/Schema/ObjectSchema.php @@ -0,0 +1,155 @@ + + */ + private array $objectSchema; + + /** + * @var array<\Closure(mixed, array &$parseError): mixed> + */ + private array $transform = []; + private mixed $default = null; + + /** + * @var \Closure(mixed, ParseErrorInterface): mixed + */ + private mixed $catch = null; + + private bool $nullable = false; + + /** + * @param array $objectSchema + * @param class-string $classname + */ + public function __construct(array $objectSchema, private string $classname) + { + foreach ($objectSchema as $name => $fieldSchema) { + if (!\is_string($name)) { + $type = \is_object($name) ? $name::class : \gettype($name); + + throw new \InvalidArgumentException(sprintf('Argument #1 name #%s ($parseErrors) must be of type string, %s given', (string) $name, $type)); + } + + if (!$fieldSchema instanceof SchemaInterface) { + $type = \is_object($fieldSchema) ? $fieldSchema::class : \gettype($fieldSchema); + + throw new \InvalidArgumentException(sprintf('Argument #1 value of #%s ($parseErrors) must be of type ParseError, %s given', (string) $name, $type)); + } + } + + $this->objectSchema = $objectSchema; + } + + public function parse(mixed $input): null|object + { + $input ??= $this->default; + + if (null === $input && $this->nullable) { + return null; + } + + try { + if (!\is_array($input)) { + throw new ParseError(sprintf("Input needs to be array, '%s'", \gettype($input))); + } + + $output = new $this->classname(); + $parseErrors = []; + + foreach (array_keys($input) as $property) { + if (!isset($this->objectSchema[$property])) { + $parseErrors[$property] = new ParseError(sprintf("Additional property '%s'", $property)); + } + } + + foreach ($this->objectSchema as $property => $fieldSchema) { + try { + $output->{$property} = $fieldSchema->parse($input[$property] ?? null); + } catch (ParseErrorInterface $parseError) { + $parseErrors[$property] = $parseError; + } + } + + foreach ($this->transform as $transform) { + $output = $transform($output, $parseErrors); + } + + if (\count($parseErrors)) { + throw new ParseErrors($parseErrors); + } + + return $output; + } catch (ParseErrorInterface $parseError) { + if ($this->catch) { + return ($this->catch)($input, $parseError); + } + + throw $parseError; + } + } + + public function safeParse(mixed $input): Result + { + try { + return new Result($this->parse($input), null); + } catch (ParseErrorInterface $parseError) { + return new Result(null, $parseError); + } + } + + /** + * @param \Closure(mixed $input, array &$parseError): mixed $transform + */ + public function transform(\Closure $transform): static + { + $this->transform[] = $transform; + + return $this; + } + + public function default(mixed $default): static + { + $this->default = $default; + + return $this; + } + + /** + * @param \Closure(mixed $input, ParseErrorInterface $parseError): mixed $catch + */ + public function catch(\Closure $catch): static + { + $this->catch = $catch; + + return $this; + } + + public function nullable(): static + { + $this->nullable = true; + + return $this; + } + + /** + * @return array + */ + public function array(): array + { + return [ + clone $this, + ]; + } +} diff --git a/src/Schema/SchemaInterface.php b/src/Schema/SchemaInterface.php new file mode 100644 index 0000000..782eab7 --- /dev/null +++ b/src/Schema/SchemaInterface.php @@ -0,0 +1,34 @@ + &$parseError): mixed $transform + */ + public function transform(\Closure $transform): static; + + public function default(mixed $default): static; + + /** + * @param \Closure(mixed $input, ParseErrorInterface $parseError): mixed $catch + */ + public function catch(\Closure $catch): static; + + public function nullable(): static; + + /** + * @return array + */ + public function array(): array; +} diff --git a/src/Schema/StringSchema.php b/src/Schema/StringSchema.php new file mode 100644 index 0000000..9b71368 --- /dev/null +++ b/src/Schema/StringSchema.php @@ -0,0 +1,114 @@ + &$parseError): mixed> + */ + private array $transform = []; + + private mixed $default = null; + + /** + * @var \Closure(mixed, ParseErrorInterface): mixed + */ + private mixed $catch = null; + + private bool $nullable = false; + + public function parse(mixed $input): null|string + { + $input ??= $this->default; + + if (null === $input && $this->nullable) { + return null; + } + + try { + if (!\is_string($input)) { + throw new ParseError(sprintf("Type should be 'string' '%s' given", \gettype($input))); + } + + $output = $input; + $parseErrors = []; + + foreach ($this->transform as $transform) { + $output = $transform($output, $parseErrors); + } + + if (\count($parseErrors)) { + throw new ParseErrors($parseErrors); + } + + return $output; + } catch (ParseErrorInterface $parseError) { + if ($this->catch) { + return ($this->catch)($input, $parseError); + } + + throw $parseError; + } + } + + public function safeParse(mixed $input): Result + { + try { + return new Result($this->parse($input), null); + } catch (ParseErrorInterface $parseError) { + return new Result(null, $parseError); + } + } + + /** + * @param \Closure(mixed $input, array &$parseError): mixed $transform + */ + public function transform(\Closure $transform): static + { + $this->transform[] = $transform; + + return $this; + } + + public function default(mixed $default): static + { + $this->default = $default; + + return $this; + } + + /** + * @param \Closure(mixed $input, ParseErrorInterface $parseError): mixed $catch + */ + public function catch(\Closure $catch): static + { + $this->catch = $catch; + + return $this; + } + + public function nullable(): static + { + $this->nullable = true; + + return $this; + } + + /** + * @return array + */ + public function array(): array + { + return [ + clone $this, + ]; + } +} diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Integration/ParserTest.php b/tests/Integration/ParserTest.php new file mode 100644 index 0000000..6835262 --- /dev/null +++ b/tests/Integration/ParserTest.php @@ -0,0 +1,42 @@ +array( + $p->object(['firstname' => $p->string()->nullable(), + 'lastname' => $p->string()->default('Doe'), + ], $person::class) + ); + + $result = $schema->safeParse([ + ['firstname' => 'Jane', 'lastname' => 'Doe'], + ['firstname' => 'John', 'lastname' => 'Doe'], + [], + ]); + + var_dump($result); + + self::assertCount(3, $result->data); + } +}